Compare commits

...

136 Commits

Author SHA1 Message Date
a620906209 d1fe3d4138 Merge pull request 'docs(readme): 服務 URL 表格補上生產環境對應網址' (#54) from feat/swagger-coverage-gaps into master
Deploy Production / deploy (push) Successful in 13s
Run Tests / test (push) Successful in 29s
Reviewed-on: #54
2026-06-23 08:05:36 +00:00
a620906209 0bffa909e2 docs(readme): 服務 URL 表格補上生產環境對應網址
Run Tests / test (pull_request) Successful in 32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:03:11 +08:00
a620906209 9222bf17fc Merge pull request 'fix(swagger): 修正 VPS 無法顯示 Swagger UI 的問題' (#53) from feat/swagger-coverage-gaps into master
Deploy Production / deploy (push) Successful in 17s
Run Tests / test (push) Successful in 33s
Reviewed-on: #53
2026-06-23 07:27:32 +00:00
a620906209 b218fa0964 fix(swagger): 修正 VPS 無法顯示 Swagger UI 的問題
Run Tests / test (pull_request) Successful in 33s
問題根因:
1. deploy.yml 未執行 l5-swagger:generate,VPS 上不存在 api-docs.json
2. @OA\Server 使用相對路徑 "/api",Swagger UI Try it out 無法正確打到 VPS API

修正:
- @OA\Server 改用 L5_SWAGGER_CONST_HOST 常數,透過 .env 各環境設定
- deploy.yml 在 config:cache 後補上 l5-swagger:generate 步驟
- .env.example 補上 L5_SWAGGER_CONST_HOST 與 L5_SWAGGER_GENERATE_ALWAYS 說明

VPS .env 需手動加入:
L5_SWAGGER_CONST_HOST=https://api.hank-space.com/api

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:23:18 +08:00
a620906209 a7e8468613 Merge pull request 'docs(swagger): 補全缺漏端點標註 + 更新 README 文件 URL' (#52) from feat/swagger-coverage-gaps into master
Deploy Production / deploy (push) Successful in 15s
Run Tests / test (push) Successful in 31s
Reviewed-on: #52
2026-06-23 07:09:07 +00:00
a620906209 60ff56a571 docs(swagger): 補全缺漏端點標註 + 更新 README 文件 URL
Run Tests / test (pull_request) Successful in 32s
新增 11 個先前缺漏的 @OA 標註:
- Token Refresh(Member / Provider / Admin)
- Provider 資格驗證申請(show / upload / delete cert / submit)
- 即時訊息聊天室(unread-counts / list / send / mark-read)

其他:
- l5-swagger.php 標題改為 CFDive Platform API
- README 補充 Swagger UI URL 與 JSON 端點位置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:08:15 +08:00
a620906209 4afe7b5fd5 Merge pull request 'chore(openspec): archive cloud-ready-p1 + sync main specs' (#51) from chore/openspec-archive-cloud-ready-p1 into master
Deploy Production / deploy (push) Successful in 14s
Run Tests / test (push) Successful in 32s
Reviewed-on: #51
2026-06-17 19:11:04 +00:00
a620906209 c3572682ad chore(openspec): 歸檔 cloud-ready-p1 + 同步主規格
Run Tests / test (pull_request) Successful in 30s
- 所有 task 驗證完成(7.1 stderr log / 7.2 無新增日誌檔 / 7.3 session 走 Redis)
- 同步 env-cloud-annotations 主 spec(LOG_CHANNEL 取代 LOG_STACK + 新增 scenario)
- 新增 scheduler-container 主 spec(獨立 container 取代 cron daemon)
- 移至 archive/2026-06-18-cloud-ready-p1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 03:05:57 +08:00
a620906209 13b6b4145c Merge pull request 'feat(infra): 雲端就緒 P1 — Session/Log/Scheduler 改造' (#50) from feat/cloud-ready-p1 into master
Deploy Production / deploy (push) Successful in 12s
Run Tests / test (push) Successful in 31s
Reviewed-on: #50
2026-06-17 18:34:47 +00:00
a620906209 d423cf5079 feat(infra): 雲端就緒 P1 — Session/Log/Scheduler 改造
Run Tests / test (pull_request) Successful in 29s
- Dockerfile:移除 cron 安裝與 scheduler crontab 設定
- docker-entrypoint.sh:移除 service cron start
- docker-compose.yml:新增 scheduler service(php artisan schedule:work)
- .env.example:SESSION_DRIVER=redis、LOG_CHANNEL=stderr、移除 LOG_STACK/LOG_DAILY_DAYS
- docs/STARTUP.md:新增 Log 查看、VPS 維護窗口操作與 Rollback 說明

VPS 部署需手動更新 .env 並執行 docker compose up -d --build(詳見 STARTUP.md §11)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:34:01 +08:00
a620906209 55b2bff1b8 Merge pull request 'chore(openspec): 歸檔 cloud-ready-zero-impact + 同步主規格' (#49) from feat/cloud-ready-zero-impact into master
Deploy Production / deploy (push) Successful in 12s
Run Tests / test (push) Successful in 29s
Reviewed-on: #49
2026-06-17 18:02:18 +00:00
a620906209 e08c6652a5 chore(openspec): 歸檔 cloud-ready-zero-impact + 同步主規格
Run Tests / test (pull_request) Successful in 29s
- openspec/changes/archive/2026-06-18-cloud-ready-zero-impact/:change 歸檔
- openspec/specs/compose-cloud-baseline/:新增主規格(healthcheck + override 隔離)
- openspec/specs/env-cloud-annotations/:新增主規格(.env.example 雲端標示)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:01:12 +08:00
a620906209 2d14c058d1 Merge pull request 'feat(infra): 雲端就緒零影響改動 (compose overlay + healthcheck + env)' (#48) from feat/cloud-ready-zero-impact into master
Deploy Production / deploy (push) Successful in 14s
Run Tests / test (push) Successful in 32s
Reviewed-on: #48
2026-06-17 17:49:29 +00:00
a620906209 be629feef9 feat(infra): 雲端就緒零影響改動 (compose overlay + healthcheck + env)
Run Tests / test (pull_request) Successful in 30s
- docker-compose.yml:app healthcheck 改用 nc -z 127.0.0.1 9000 驗證 PHP-FPM port
- docker-compose.yml:移除 phpmyadmin、mailpit(開發工具不屬於正式服務)
- compose.override.yml:新增,含 phpmyadmin(8081)、mailpit(8025)port mapping
- .env.example:QUEUE_CONNECTION 預設改 redis;補充 FILESYSTEM_DISK、LOG_STACK、SESSION_DRIVER 雲端注意事項
- docs/STARTUP.md:新增「部署模式」章節,明確區分 VPS 與雲端 -f 指令

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 01:48:24 +08:00
a620906209 998f34139a Merge pull request 'fix(ci): 修正 Alpine 3.23 PHP 套件名稱為 php84' (#47) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 38s
Reviewed-on: #47
2026-06-17 16:38:17 +00:00
a620906209 0080e2da84 fix(ci): 修正 Alpine 3.23 PHP 套件名稱為 php84
Run Tests / test (pull_request) Successful in 37s
Alpine 3.23 已無 php82 套件,改用 php84。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 00:37:46 +08:00
a620906209 4720dc53f9 Merge pull request 'fix(ci): 替換 setup-php 為 Alpine apk 直接安裝 PHP 8.2' (#46) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 17s
Run Tests / test (push) Failing after 12s
Reviewed-on: #46
2026-06-17 16:34:07 +00:00
a620906209 95a93b2d72 fix(ci): 替換 setup-php 為 Alpine apk 直接安裝 PHP 8.2
Run Tests / test (pull_request) Failing after 7s
shivammathur/setup-php 在 Alpine runner 上需要 sudo 且編譯耗時。
改用 apk add php82 系列套件,速度更快且相容 Alpine 環境。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 00:32:44 +08:00
a620906209 c0616b17df Merge pull request 'ci: 觸發 test workflow 驗證 Node.js 環境' (#45) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 18s
Run Tests / test (push) Failing after 5s
Reviewed-on: #45
2026-06-17 16:04:57 +00:00
a620906209 6a74f766db ci: 觸發 test workflow 驗證 Node.js 環境
Run Tests / test (pull_request) Has been cancelled
2026-06-18 00:04:28 +08:00
a620906209 d8340de322 Merge pull request 'fix(ci): test.yml 改用 self-hosted runner 避免 Docker-in-Docker 衝突' (#44) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Failing after 4s
Reviewed-on: #44
2026-06-17 15:49:27 +00:00
a620906209 aebce80152 fix(ci): test.yml 改用 self-hosted runner 避免 Docker-in-Docker 衝突
Run Tests / test (pull_request) Failing after 42s
ubuntu-latest 觸發 container 模式,導致 mkdirat var/run: file exists 錯誤。
改成 self-hosted 與 deploy.yml 一致,直接跑在 host 模式。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 23:48:27 +08:00
a620906209 5fdd5b3329 Merge pull request 'fix(vps): 加 TrustProxies、Redis 持久化 Volume、Log 改 daily' (#43) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 21s
Run Tests / test (push) Failing after 8s
Reviewed-on: #43
2026-06-17 15:44:47 +00:00
a620906209 daf72711e8 Merge pull request 'chore: 加入 Windsurf 規則與啟動文件' (#42) from chore/windsurf-startup-docs into master
Deploy Production / deploy (push) Successful in 25s
Run Tests / test (push) Successful in 2m14s
Reviewed-on: #42
2026-06-17 15:41:25 +00:00
a620906209 2fbcd0c50d fix(vps): 加 TrustProxies、Redis 持久化 Volume、Log 改 daily
Run Tests / test (pull_request) Successful in 2m15s
- bootstrap/app.php: trustProxies(at: ['*']) 讓 NPM 反向代理的 X-Forwarded-* header 被信任
- docker-compose.yml: Redis 加 redis-data volume,重啟後 session/queue/cache 不再清空
- .env.example: LOG_STACK 改 daily + LOG_DAILY_DAYS=14,避免 VPS 磁碟被單一 log 吃滿

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 23:33:36 +08:00
a620906209 979ea4df75 chore: 加入 Windsurf 規則與啟動文件
Run Tests / test (pull_request) Failing after 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 23:31:52 +08:00
a620906209 6b1e31c4af Merge pull request 'docs: 補充測試帳號與功能概覽,修正 .env.example REVERB 區塊' (#41) from docs/interview-guest-accounts into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 2m3s
Reviewed-on: #41
2026-06-16 22:07:34 +00:00
a620906209 64874e1c12 docs: 補充測試帳號與功能概覽,修正 .env.example REVERB 區塊
Run Tests / test (pull_request) Successful in 2m0s
- README:更新功能概覽(Token 刷新、圖片壓縮、教練審核流程、測試欄)
  新增測試帳號表格與教練頁入口說明(/coach/login)
- .env.example:刪除誤植的 README 殘留行,補上完整 REVERB_* 與 VITE_REVERB_* 區塊
- .gitignore:忽略 .windsurf/rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 05:59:01 +08:00
a620906209 19705fc8d7 Merge pull request 'chore(openspec): 歸檔 feature-test-coverage、provider-verification-workflow 變更' (#40) from chore/archive-changes-jun16 into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 1m50s
Reviewed-on: #40
2026-06-16 11:22:36 +00:00
a620906209 80f5cf179f chore(openspec): 歸檔 feature-test-coverage、provider-verification-workflow 變更
Run Tests / test (pull_request) Failing after 1m53s
兩個 change 全任務完成(feature-test-coverage 40/40、provider-verification-workflow 48/48)。

同步規格:
- feature-test-coverage/spec.md 補齊 ## Requirements 區塊標頭(結構修正)
- Admin 教練審核 scenario 改為 approve 端點(toggle-verified 已由 #36 移除)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:19:52 +08:00
a620906209 d99ffb0fd6 Merge pull request 'fix(tests): 同步 Feature test 與 provider-verification-workflow 架構變更' (#39) from fix/feature-test-sync into master
Deploy Production / deploy (push) Successful in 14s
Run Tests / test (push) Successful in 1m56s
Reviewed-on: #39
2026-06-16 11:18:04 +00:00
a620906209 afdca18c67 fix(tests): 同步 Feature test 與 provider-verification-workflow 架構變更
Run Tests / test (pull_request) Failing after 2s
provider-verification-workflow (#36) 在 feature-test-coverage (#38) merge 前
已將 is_verified 欄位替換為 verification_status 狀態機,並移除 toggle-verified 路由。

- 五個 Feature test 的 makeProvider helper 改用 verification_status='approved'
  (原 is_verified 已從 provider_profiles 欄位移除,靜默忽略導致 422/404)
- AdminUserManagementTest: toggle-verified 案例改為測試新的 approve 端點
  (PUT /admin/verifications/{userId}/approve,需 pending 狀態才可核准)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:14:25 +08:00
a620906209 4383239bab Merge pull request 'test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表' (#38) from feat/feature-test-coverage into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Failing after 1m50s
Reviewed-on: #38
2026-06-16 10:24:17 +00:00
a620906209 49a4b89655 test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表
Run Tests / test (pull_request) Successful in 1m51s
ProviderOfferCrudTest(7 案例):store/update/destroy 正常流程 + 所有權邊界(403)+ 未認證(401)
ProviderScheduleCrudTest(7 案例):store/update/destroy + 容量不變式(422)+ destroy 同步取消 active bookings
AdminUserManagementTest(5 案例):列表角色過濾 + toggle-active/toggle-verified DB 副作用 + 非 admin 403
NotificationTriggerTest(5 案例):5 條直接觸發路徑,收件者各不同(BookingCreated/CancelledByMember → provider;其餘 → member)
BookingListTest(4 案例):三角色資料隔離邊界 + 未認證 401

全套件 215 passed / 529 assertions,零 regression。
同步新增 openspec/changes/feature-test-coverage/ 與 openspec/specs/feature-test-coverage/spec.md。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:23:11 +08:00
a620906209 66ff30f863 Merge pull request 'test(unit): 補齊三個純邏輯 Unit test——狀態機、Enum 完整性、Email 正規化' (#37) from feat/unit-test-planning into master
Run Tests / test (push) Successful in 1m53s
Deploy Production / deploy (push) Successful in 11s
Reviewed-on: #37
2026-06-16 09:35:25 +00:00
a620906209 d6cc1cbca9 test(unit): 補齊三個純邏輯 Unit test——狀態機、Enum 完整性、Email 正規化
Run Tests / test (pull_request) Failing after 1m8s
BookingTransitionTest:驗證 canTransitionTo() 所有合法/非法轉換與終態不可逃脫(18 案例)
BookingStatusTest:驗證 7 個狀態完整性、from/tryFrom、VALID_TRANSITIONS 覆蓋一致性(10 案例)
NormalizesEmailTest:驗證大小寫與空白正規化邏輯(5 案例)

全部使用 PHPUnit 原生 TestCase,不需要資料庫連線,執行 1.82s。
全套件 187 passed / 464 assertions,零 regression。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:31:48 +08:00
a620906209 a18f52b2e8 Merge pull request 'chore(openspec): 歸檔 course-image-compression 變更' (#35) from chore/archive-cic into master
Deploy Production / deploy (push) Successful in 12s
Run Tests / test (push) Successful in 6m11s
Reviewed-on: #35
2026-06-11 19:41:00 +00:00
a620906209 e40507bd60 Merge pull request 'feat: 教練審核流程完整版——四狀態機、證照送審、Admin 裁決、通知(含測試清空 DB 重大修復)' (#36) from feat/provider-verification-workflow into master
Deploy Production / deploy (push) Successful in 33s
Run Tests / test (push) Successful in 1m54s
Reviewed-on: #36
2026-06-11 19:40:52 +00:00
a620906209 43720482c4 feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
Run Tests / test (pull_request) Successful in 2m3s
實作 openspec change provider-verification-workflow(25/25 tasks):
- is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/
  approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留
  is_verified 相容輸出
- 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因
  顯示與重送(/coach/verification 新頁面)
- Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除
  toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取
- 通知:審核通過/駁回(站內+Email,駁回含原因)
- 可見性與預約入口改判 approved(行為等價)
- 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8),
  既有 4 個測試檔 helper 遷移;Swagger 同步
- 規格同步:provider-verification 全面改寫、admin-user-management
  toggle 段落改為移除聲明

容器內全套件 173 passed / 439 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:12 +08:00
a620906209 37140554d3 fix(test): 防止容器內測試清空開發用 MySQL——bootstrap 強制覆蓋 $_SERVER
根因:docker compose 將 DB_CONNECTION=mysql 注入為真實環境變數,
PHPUnit 的 <env force> 只覆蓋 $_ENV/putenv,但 Laravel env() 優先讀
$_SERVER,導致 RefreshDatabase 一直在清空開發 DB(今日實際發生三次,
也是先前『DB 神秘清空』的元凶)。

- 新增 tests/bootstrap.php 於載入前同步覆蓋 $_SERVER/$_ENV/putenv
- phpunit.xml bootstrap 指向新檔,DB env 加 force 屬性
- 驗證:容器內全套件後 MySQL 資料存活(users=13)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:25:54 +08:00
a620906209 a7d5f81890 chore(openspec): 歸檔 course-image-compression 變更
Run Tests / test (pull_request) Successful in 1m58s
實作已 merge(PR #34),規格增量已於實作時同步至主規格。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:53:15 +08:00
a620906209 c53e9657b3 Merge pull request 'feat(images): 課程圖片伺服器端壓縮(O3.1)——scaleDown 2048 + JPEG 85、上限放寬至 10MB' (#34) from feat/course-image-compression into master
Deploy Production / deploy (push) Successful in 1m4s
Run Tests / test (push) Successful in 2m27s
Reviewed-on: #34
2026-06-11 15:47:34 +00:00
a620906209 6414222a18 feat(images): 課程圖片伺服器端壓縮——scaleDown 2048 + JPEG 85,上限放寬至 10MB
Run Tests / test (pull_request) Successful in 2m0s
實作 openspec change course-image-compression(O3.1 體感優化):
- 新增 App\Traits\CompressesImages:與聊天圖片管線參數一致的共用壓縮邏輯
- CourseImageController 封面/相簿套用壓縮,上傳上限 2MB→10MB(容納手機原圖)
- 前端課程卡片與相簿縮圖加 loading=lazy(詳情頁首屏封面刻意不加)
- CourseImageTest +4 案例(10MB 邊界、轉存 .jpg、>2048 縮小、小圖不放大)
- course-image-upload 主規格同步(新增伺服器端壓縮 requirement)

容器內全套件 155 passed / 400 assertions。
聊天端改用 trait 留待後續(該處無測試覆蓋,不在本 change 重構)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:43:24 +08:00
a620906209 87a4f70e65 Merge pull request 'chore(openspec): 歸檔 provider-verification-gaps 變更' (#32) from chore/archive-pvg into master
Deploy Production / deploy (push) Successful in 29s
Run Tests / test (push) Failing after 2m16s
Reviewed-on: #32
2026-06-11 15:21:02 +00:00
a620906209 99651b0bac Merge pull request 'perf(docker): Phase 1 效能優化——OPcache revalidate 調校、composer 重裝修正、healthcheck 修復' (#33) from perf/phase1-opcache into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 2m14s
Reviewed-on: #33
2026-06-11 15:20:41 +00:00
a620906209 07618fb1b3 perf(docker): OPcache revalidate 調校 + composer 重裝條件修正 + nginx healthcheck 修復
Run Tests / test (pull_request) Successful in 1m56s
實測修正根因:fpm 的 OPcache 本來就啟用(初版診斷是 CLI 假象),
真凶是 revalidate_freq=2 在 Windows bind mount 上每 2 秒重新 stat
全部腳本——窗口內 0.23s、窗口外 2.5s。

- local.ini:revalidate_freq 2→30、memory 192M、max_files 20000
  (程式碼變更最多 30s 生效,立即生效用 kill -USR2 1 平滑重載)
- entrypoint:composer 重裝改用 lock 內容比對,git 分支操作不再
  觸發開機重裝(原 mtime 比對造成數分鐘 502 窗口)
- healthcheck:localhost→127.0.0.1(busybox wget 先解析 ::1 而
  nginx 只 listen IPv4,導致永遠 unhealthy)

驗收:穩態 0.20~0.27s(修復前 2.3~2.5s,約 10 倍);nginx healthy。
量測數據已寫回優化計畫文檔。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:15:55 +08:00
a620906209 5825e74d2d chore(openspec): 歸檔 provider-verification-gaps 變更
Run Tests / test (pull_request) Successful in 1m59s
實作已 merge(PR #31),規格增量已於實作時同步至主規格。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:59:32 +08:00
a620906209 3384210c5c Merge pull request 'feat: 補齊 provider-verification 可見性缺口——子端點過濾與預約入口檢查' (#31) from feat/provider-verification-gaps into master
Deploy Production / deploy (push) Successful in 17s
Run Tests / test (push) Successful in 2m7s
Reviewed-on: #31
2026-06-11 14:52:54 +00:00
a620906209 90d0a57e73 feat(offers): 補齊 provider-verification 可見性缺口——子端點過濾與預約入口檢查
Run Tests / test (pull_request) Successful in 1m56s
實作 openspec change provider-verification-gaps(8/8 tasks):
- schedules/reviews publicList 套用 visibleToPublic,隱藏課程回 404
- MemberBookingController::store Layer 1 擋未驗證教練課程的新預約(422)
- 政策:教練被取消驗證只擋新預約,既有 pending/confirmed/completed 照常
- 主規格 Notes 已知限制轉為正式 Requirements
- 測試 +5(可見性子端點 3、預約入口 2),既有 booking/review 測試 helper
  補 verified ProviderProfile
- 容器內全套件 151 passed / 388 assertions;另以 demo 資料端到端驗證
  (已驗證教練 200/200/200、未驗證 404/404/404)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:48:59 +08:00
a620906209 c226275fc2 Merge pull request 'docs: 稽核文檔收斂為執行紀錄、開 provider-verification-gaps change 規劃遺留缺口' (#30) from docs/post-audit-planning into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Successful in 2m4s
Reviewed-on: #30
2026-06-11 14:33:12 +00:00
a620906209 99cf35c1ec docs: 稽核文檔改為執行紀錄、開 provider-verification-gaps change 規劃遺留缺口
Run Tests / test (pull_request) Successful in 2m1s
- 稽核報告:已完成的修補計畫改寫為執行紀錄(commit 對照表 + 偏差說明),
  未處理事項收斂為三項(verification 缺口 / 效能優化 / 新功能路線圖)
- 新 change provider-verification-gaps:規劃 schedules/reviews 公開子端點
  過濾與預約入口檢查(含「既有預約不受影響」政策決定),待實作

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:06:34 +08:00
a620906209 520ec487ee Merge pull request 'chore(openspec): 補歸檔 2026-06-11-audit-remediation change' (#29) from fix/audit-remediation into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 1m53s
Reviewed-on: #29
2026-06-11 10:46:30 +00:00
a620906209 a97a7d096c chore(openspec): 補歸檔 2026-06-11-audit-remediation change
Run Tests / test (pull_request) Successful in 2m2s
事後補齊本次稽核修補的 OpenSpec 歷史紀錄(proposal / design /
tasks / 規格增量),主規格已於實作時直接更新,本 archive 僅為
變更軌跡。design.md 記錄 5 項關鍵決策(artisan command 取代
HTTP 端點、10/min 為基準、列表過濾而非禁止上架等)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:44:14 +08:00
a620906209 0dffca31bb Merge pull request 'security: 2026-06-11 稽核修補——封鎖 admin/register P0 漏洞、is_verified 業務約束、預約核心測試' (#28) from fix/audit-remediation into master
Deploy Production / deploy (push) Successful in 20s
Run Tests / test (push) Successful in 2m9s
Reviewed-on: #28
2026-06-11 10:21:31 +00:00
a620906209 7ca91da138 docs: 操作體驗優化計畫——實測定位 OPcache 停用 + bind mount 為主因
Run Tests / test (pull_request) Successful in 2m7s
實測:API 快取命中仍 2.49s、容器測試比本機慢 14 倍;
容器內 opcache 已載入但 disabled。計畫分三階段,O1.1 啟用
OPcache 為最高投報率項目。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:55:56 +08:00
a620906209 cec2078035 test(auth): 同步 admin register 端點移除——刪除過時測試、更新覆蓋規格
原 test_admin_register_* 測的是已移除的 P0 漏洞端點,
端點關閉防護與帳號建立改由 AdminAccountCreationTest 覆蓋。
容器內全套件 146 passed / 378 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:52:37 +08:00
a620906209 d3a0d300c6 docs(openspec): 規格清理——補 Purpose、修正前端 repo 描述、補 admin 查詢端點
- auth-test-coverage:補歸檔後遺留的 TBD Purpose
- member-portal-ui:前端實際位於本 repo frontend/,修正獨立 repo 描述
- admin-auth:補 check-member / check-provider 端點規格

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:49:00 +08:00
a620906209 63b25f96ac test(booking): 補預約核心測試——狀態機/防超賣/Scheduler/聊天授權/Admin 權限邊界
稽核 P2-1:平台核心業務(預約)原本零測試覆蓋。
- BookingLifecycleTest:七狀態機合法/非法轉移、名額帳務、24h 取消截止、授權邊界(17 案例)
- BookingOversellTest:confirm 時 lockForUpdate 防超賣不變式、名額釋放回收(3 案例)
- BookingSchedulerTest:48h 過期與日期完成的時間邊界(6 案例)
- BookingChatAuthTest:HTTP 與 presence channel 雙防線(8 案例)
- AdminEndpointAuthTest:非 admin 一律 403、Admin 不可繞過狀態機(5 案例)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:46:46 +08:00
a620906209 3c38d085bd feat(offers): 未驗證教練的課程不對公開端點曝光
is_verified 原本只有 Admin toggle 開關、無任何業務約束(稽核 P1-1)。
- DivingOffer 新增 visibleToPublic scope:provider_id null 或教練已驗證
- 公開 index/show 套用過濾,未驗證教練課程列表排除、詳情 404
- toggle-verified 後 flush diving_offers 快取標籤,切換立即生效
- 新增 provider-verification 規格(含已知限制註記)與 7 條可見性測試

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:42:43 +08:00
a620906209 88a81aac41 docs(openspec): 同步 login-rate-limiting 規格門檻至實作值 10/min
規格原寫 5/min,實作與測試(commit 0dabc4e)皆為 throttle:10,1,
以實作為準消除規格漂移,並註記放寬理由(帳號鎖定已涵蓋暴力破解防護)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:37:19 +08:00
a620906209 aeb8c975e2 security(auth): 移除公開 admin/register 端點,改由 app:create-admin 建立管理員
P0 漏洞:原 POST /api/admin/register 無任何防護,任何人可註冊管理員帳號。
- 移除路由、AuthController::registerAdmin、對應 Swagger 文件
- 新增 app:create-admin artisan command(密碼門檻 min:8)
- 補 AdminAccountCreationTest 防止端點被重新打開
- 同步 admin-auth 規格,並收錄 2026-06-11 稽核報告與路線圖文檔

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:36:33 +08:00
a620906209 51784ecc64 Merge pull request 'fix(test): 修正 AuthRateLimitTest 過期的 throttle 門檻斷言' (#27) from test/auth-tests-coverage into master
Run Tests / test (push) Successful in 2m35s
Deploy Production / deploy (push) Successful in 17s
Reviewed-on: #27
2026-06-07 20:06:03 +00:00
a620906209 ca5b843362 chore(openspec): 歸檔 auth-tests 變更並建立 auth-test-coverage 主規格
Run Tests / test (pull_request) Failing after 43s
34 項驗收情境與測試已 1:1 對應且全數通過,將變更移至
openspec/changes/archive/2026-06-07-auth-tests/,並依其內容建立
openspec/specs/auth-test-coverage/spec.md 作為主規格文件。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:02:05 +08:00
a620906209 6f446f601f test(auth): 新增三角色登入/註冊/登出、P2 帳號鎖定、OAuth state 驗證測試
新增 34 個 Feature 測試(AuthLoginTest 19、AuthLockoutTest 11、
AuthOAuthTest 4)涵蓋 member/provider/admin 的註冊、登入、登出與跨角色
隔離;P2 帳號鎖定的 Fixed Window 計數、companion cache key 與 email
正規化;以及 P2 OAuth state 的 CSRF 驗證與一次性消耗。

同時新增 .gitea/workflows/test.yml 在 push/PR 時於 CI 自動執行測試
(含 GD 等擴充套件安裝),並補上對應的 OpenSpec 變更文件
(proposal/design/spec/tasks,34 項驗收情境與測試 1:1 對應)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:01:33 +08:00
a620906209 0dabc4ebb5 fix(test): 修正 AuthRateLimitTest 過期的 throttle 門檻斷言
commit 2a0e9255(P2 帳號鎖定上線)已將 member/provider 登入的 IP throttle
從 5,1 調整為 10,1(避免 IP throttle 搶在帳號鎖定的 423 之前觸發 429),
但測試的迴圈次數與註解仍停留在舊版 5,1,導致預期 429 卻收到 401。
同步將迴圈次數改為 10、區塊註解改為 throttle:10,1,使其對齊現行設定。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:01:20 +08:00
a620906209 ca1b83a2e3 Merge pull request 'Merge branch 'master' into feat/demo-seeder' (#26) from feat/demo-seeder into master
Deploy Production / deploy (push) Successful in 10s
Reviewed-on: #26
2026-06-02 17:51:49 +00:00
a620906209 855d9e1d4a Merge branch 'master' into feat/demo-seeder 2026-06-02 17:50:01 +00:00
a620906209 981f753480 Merge pull request 'fix(auth): 補 StartSession middleware 給 OAuth routes' (#24) from security/auth-p2-lockout-oauth-state into master
Deploy Production / deploy (push) Successful in 12s
Reviewed-on: #24
2026-06-02 17:47:57 +00:00
a620906209 3b90edf843 fix(auth): 補 StartSession middleware 給 OAuth routes
api.php 的 route 預設無 session middleware,
移除 stateless() 後 session() 呼叫直接 500。
只對 /auth/google/* 兩個 route 加 StartSession,不影響其他 API endpoint。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:44:29 +08:00
a620906209 b06d8524db Merge pull request 'security(auth): P2 帳號鎖定 + OAuth state 驗證' (#23) from security/auth-p2-lockout-oauth-state into master
Deploy Production / deploy (push) Successful in 31s
Reviewed-on: #23
2026-06-02 17:39:02 +00:00
a620906209 2a0e9255ae security(auth): P2 帳號鎖定 + OAuth state 驗證
- Account lockout:連續失敗 5 次鎖定 15 分鐘(Fixed Window,Cache)
  - Member / Provider 各自獨立 namespace
  - Email 正規化(strtolower+trim)
  - 不存在帳號不累計計數(防 Cache 污染)
  - companion key 記錄 locked_until(ISO 8601)
- OAuth CSRF 防護:移除 stateless(),手動 state 生成與驗證
  - session('oauth_state') + session('state') 雙寫確保 Socialite 內建驗證通過
  - state mismatch redirect 至 /login?error=oauth_failed
- throttle 從 5,1 調整為 10,1,避免 IP throttle 在 lockout 前觸發
- NormalizesEmail Trait、config/auth_lockout.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:20:25 +08:00
a620906209 b2e20767d8 Merge pull request 'security(auth): P1 Token Refresh + sessionStorage' (#22) from security/auth-p1-token-refresh into master
Deploy Production / deploy (push) Successful in 30s
Reviewed-on: #22
2026-06-01 20:34:52 +00:00
a620906209 d9f0fe11c6 security(auth): P1 Token Refresh + sessionStorage
- 新增三個 refresh 端點(member/provider/admin),revoke 舊 token 發行新 7 天 token
- axios.js / coachAxios.js:401 interceptor 改為 refresh-then-retry,含 isRefreshing queue 防並發、isRedirecting flag 防雙重 redirect
- auth.js / coachAuth.js / AuthCallbackView.vue:localStorage 全改為 sessionStorage
- echo.js / notificationAxios.js:同步改為 sessionStorage
- notifications.js:401 時停止 polling(stopPolling),不印 error log
- 新增 TokenRefreshTest.php(11 tests / 24 assertions):refresh 成功/失效/跨角色 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 04:33:35 +08:00
a620906209 a94c2462b8 Merge pull request 'security(auth): P0 認證安全強化' (#21) from security/auth-p0-hardening into master
Deploy Production / deploy (push) Successful in 36s
Reviewed-on: #21
2026-06-01 17:58:24 +00:00
a620906209 cb9c9a9385 security(auth): P0 認證安全強化
- Token 過期時間設為 7 天(sanctum.php expiration)
- prune-expired 每日排程清除過期 token 記錄
- Google OAuth redirect 改用 URL fragment(#token=)避免 token 出現在 server log
- SocialAuthController env() 改 config() 修正 config:cache 下 redirect 失效問題
- 登入端點加 rate limiting:member/provider throttle:5,1,admin throttle:3,1
- axios/coachAxios 加 401 response interceptor,token 過期自動登出
- 修正 migration SQLite 相容性(MODIFY COLUMN),Feature tests 從 7 通過恢復至 41
- 新增 AuthRateLimitTest(5 tests)驗證各角色頻率限制行為

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 01:54:40 +08:00
a620906209 5b13a014be Merge pull request 'docs: 更新 README 反映最新功能與技術棧' (#20) from docs/update-readme into master
Deploy Production / deploy (push) Successful in 14s
Reviewed-on: #20
2026-05-28 19:18:17 +00:00
a620906209 8d1ea49abc docs: 更新 README 反映最新功能與技術棧
新增:即時訊息、Reverb WebSocket、Sentry、/health、CI/CD、本地開發說明。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 03:17:44 +08:00
a620906209 9be50d493b Merge pull request 'fix: 改用 BusyBox 相容的 flock 語法' (#19) from fix/sentry-composer into master
Deploy Production / deploy (push) Successful in 21s
Reviewed-on: #19
2026-05-28 19:04:47 +00:00
a620906209 0a6bfd9df8 fix: 改用 BusyBox 相容的 flock 語法
BusyBox flock 不支援 -w,移除 timeout 參數。
等待行為不變,預設會阻塞直到取得鎖。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 03:04:12 +08:00
a620906209 127101adb3 Merge pull request 'fix: 修正 CI 並發 fetch 導致 git ref 衝突' (#18) from fix/sentry-composer into master
Deploy Production / deploy (push) Failing after 1s
Reviewed-on: #18
2026-05-28 19:03:13 +00:00
a620906209 0160d72467 fix: 修正 CI 並發 fetch 導致 git ref 衝突
git pull 改為 flock + fetch + reset --hard:
- flock 防止兩次 CI 同時更新 ref 產生 race condition
- reset --hard 比 pull 更適合 CI 環境,不做 merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 03:00:57 +08:00
a620906209 cfd0455597 Merge pull request 'fix: 補上 sentry/sentry-laravel 的 composer 異動' (#17) from fix/sentry-composer into master
Deploy Production / deploy (push) Failing after 1s
Reviewed-on: #17
2026-05-28 18:38:45 +00:00
a620906209 8974a22584 fix: 補上 sentry/sentry-laravel 的 composer 異動
前次 Sentry 整合 commit 漏掉 composer.json / composer.lock。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 02:38:06 +08:00
a620906209 9bde1ae344 Merge pull request 'feat: 整合 Sentry 錯誤監控' (#16) from feature/health-endpoint into master
Deploy Production / deploy (push) Successful in 16s
Reviewed-on: #16
2026-05-28 18:32:11 +00:00
a620906209 3ad5972a5a feat: 整合 Sentry 錯誤監控
安裝 sentry/sentry-laravel,掛載例外捕捉至 bootstrap/app.php。
.env 透過 SENTRY_LARAVEL_DSN 設定,空值時自動停用。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 02:29:00 +08:00
a620906209 b770aab42a Merge pull request 'refactor: 精簡 /health 回應欄位' (#15) from feature/health-endpoint into master
Deploy Production / deploy (push) Successful in 16s
Reviewed-on: #15
2026-05-28 18:05:29 +00:00
a620906209 8d41f473e4 refactor: 精簡 /health 回應欄位
移除 php_version、laravel、environment、memory_mb,
保留 status、timestamp、checks 三欄。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 02:04:24 +08:00
a620906209 5463fd70b1 Merge pull request 'chore:新增 swagger-api-docs-completion change 規劃' (#14) from feature/health-endpoint into master
Deploy Production / deploy (push) Successful in 25s
Reviewed-on: #14
2026-05-28 17:58:55 +00:00
a620906209 54dd4bb7a6 feat: 新增 /health 端點供 UptimeRobot 監控
檢查 DB 連線、Redis ping、Cache 讀寫,任一失敗回傳 503。
回應包含 timestamp、environment、PHP/Laravel 版本、記憶體用量。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:57:04 +08:00
a620906209 71b35533f4 Merge branch 'master' of https://github.com/a620906209/CFDivePlatform 2026-05-29 01:20:12 +08:00
a620906209 eff2dd273c Merge pull request 'docs(openspec): archive realtime-booking-chat,同步主 specs' (#13) from feature/realtime-booking-chat into master
Deploy Production / deploy (push) Successful in 20s
Reviewed-on: #13
2026-05-28 17:18:49 +00:00
a620906209 4a5559a79e chore(openspec): 移除已 archive 的 change 原路徑
補上 mv 後未 stage 的刪除紀錄,保持 working tree 乾淨。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:17:41 +08:00
a620906209 83e0e599a0 docs(openspec): archive realtime-booking-chat,同步主 specs
- 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 <noreply@anthropic.com>
2026-05-29 01:14:52 +08:00
a620906209 6fcf57a2d2 Merge pull request 'fix: 修正聊天室雙向即時同步失效問題' (#12) from feature/realtime-booking-chat into master
Deploy Production / deploy (push) Successful in 15s
Reviewed-on: #12
2026-05-28 15:12:20 +00:00
a620906209 5ba598c78f fix: 修正聊天室雙向即時同步失效問題
- echo.js:還原 disconnect()/connect(),確保 Reverb 重連後重新授權 presence channel
- BookingChat.vue:移除 handleNotificationFallback(會覆蓋樂觀更新訊息導致重複)
- BookingChat.vue:改用 state_change 事件偵測重連('reconnected' 並非合法 Pusher.js 事件)
- BookingChat.vue:移除未使用的 useAuthStore / useCoachAuthStore / currentUserId 依賴

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 18:29:53 +08:00
a620906209 3809ac7ed0 Merge pull request 'fix: 避免 entrypoint 覆寫 .env 的 DB 憑證' (#11) from feature/realtime-booking-chat into master
Deploy Production / deploy (push) Successful in 44s
Reviewed-on: #11
2026-05-28 10:18:27 +00:00
a620906209 6b3a0816f5 fix: 聊天室樂觀更新 + private channel fallback
問題:presence channel 在另一方加入的瞬間可能短暫抖動,
導致雙方都收不到 MessageSent event,訊息消失需重整。

三項修正:
1. sendText 樂觀更新:訊息送出後立即顯示暫存訊息,
   不依賴 WebSocket event 才出現。
2. MessageSent handler deduplication:自己的訊息收到
   event 時,替換暫存訊息而非重複 push。
3. Private channel fallback:receiver 的 private channel
   收到 notification.created 時,補拉一次訊息歷史,
   確保 presence channel 失效時仍能同步。使用
   stopListening 避免干擾 notifications store 的訂閱。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:59:10 +08:00
a620906209 1ba4ca893a fix: 修正即時聊天室 presence channel 間歇性失效
三個根本原因:
1. updateEchoToken() 呼叫 disconnect/connect 會中斷已訂閱的
   presence channel,改為只更新 auth header(不重連)。
   同時將 setAuth() 的呼叫順序改為先 updateEchoToken() 再
   startRealtime(),確保訂閱時使用最新 token。
2. BookingPresenceChannel::join() 未對 schedule null 做防守,
   載入後若 schedule 不存在會 500,導致 auth 靜默失敗。
3. WebSocket 重連後 presence channel 不會自動恢復,
   在 BookingChat 加入 reconnected 事件處理,重連後重訂閱。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:46:15 +08:00
a620906209 6286399a3b fix: 修正 VITE_API_URL 預設值 typo(spack → space)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:28:17 +08:00
a620906209 f80319109c fix: entrypoint 先啟動 php-fpm,init 任務移至背景
將 wait_for_mysql、migration、cache:clear 等耗時操作移至背景
subshell 執行,php-fpm 不再等待它們完成才啟動。
解決每次 docker restart 後 php-fpm 尚未就緒時 Nginx 回傳
502(無 CORS headers),前端看到 CORS error 的問題。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:23:59 +08:00
a620906209 4cf83375f3 fix: 避免 entrypoint 覆寫 .env 的 DB 憑證
僅保留 DB_HOST 強制設為 db(Docker service name),
移除對 DB_USERNAME / DB_DATABASE / DB_PASSWORD 的覆寫。
原本透過 container 環境變數回寫 .env 的機制會在密碼含
特殊字元時因 shell 展開截斷,導致 DB_PASSWORD 被清空。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:07:29 +08:00
a620906209 91589a6f0e Merge pull request 'feat: 即時預約聊天室(realtime booking chat)' (#10) from feature/realtime-booking-chat into master
Deploy Production / deploy (push) Successful in 55s
Reviewed-on: #10
2026-05-28 07:56:10 +00:00
a620906209 ab24f210a6 feat: 即時預約聊天室(realtime booking chat)
## 新增功能
- 會員與教練在 confirmed 預約期間可互傳文字與圖片訊息
- Presence Channel 顯示對方在線狀態(即時加入/離線)
- 已讀回執:對方讀取訊息後即時更新
- 預約完成(completed)後訊息封存唯讀
- 圖片上傳使用 intervention/image 處理(移除 EXIF、限制尺寸、強制重新編碼)

## 通知系統
- 收到新訊息時 Bell Icon 即時更新(NotificationCreated via private channel)
- 預約列表卡片顯示未讀角標(GET /api/bookings/messages/unread-counts 批次查詢)
- 瀏覽器分頁在背景時推送 Web Notification

## 基礎建設
- 引入 Laravel Reverb 作為自架 WebSocket 伺服器
- docker-compose 新增 reverb service(連接 proxy_net,供 NPM 代理)
- 前端安裝 laravel-echo + pusher-js,初始化 Echo plugin
- Dockerfile 補 GD JPEG/WebP/FreeType 支援

## 清理
- 移除 test_broadcast.php 與 resources/js/echo.js(Blade 殘留)
- 移除 /ping、/testpost 測試路由
- channels.php 改用 class-based 授權語法,移除 debug log
- BookingChat.vue otherType 提取為 computed 消除重複
- docker-entrypoint.sh 健康檢查改用 env var 取代硬編碼密碼

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 03:59:14 +08:00
a620906209 cc010b5c83 fix: use self-hosted runner for deploy workflow
Deploy Production / deploy (push) Successful in 19s
2026-05-25 16:31:20 +00:00
a620906209 c10d4ebfe6 rerun deploy with self-hosted runner
Deploy Production / deploy (push) Has been cancelled
2026-05-25 16:29:10 +00:00
a620906209 153aee5f94 Merge pull request 'fix: 修正 Gitea Actions runner 標籤不符問題' (#9) from chore/openspec-gitignore-setup into master
Deploy Production / deploy (push) Has been cancelled
Reviewed-on: #9
2026-05-25 15:40:19 +00:00
a620906209 f996a0053b fix: 修正 Gitea Actions runner 標籤不符問題
將 runs-on 從 self-hosted 改為 ubuntu-latest,
與 act_runner 實際註冊的標籤一致。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:34:51 +08:00
a620906209 e7776fa930 Merge pull request 'chore: 重新命名 CLAUDE.MD 為小寫、細化 .gitignore 並加入 OpenSpec 設定' (#8) from chore/openspec-gitignore-setup into master
Deploy Production / deploy (push) Has been cancelled
Reviewed-on: #8
2026-05-25 15:23:52 +00:00
a620906209 972829419b chore: 重新命名 CLAUDE.MD 為小寫、細化 .gitignore 並加入 OpenSpec 設定
- CLAUDE.MD → CLAUDE.md(修正 Windows 大小寫問題)
- .gitignore 改為僅排除 .claude/settings.local.json 與 .claude/worktrees/
- 新增 .claude/skills/openspec-*/SKILL.md(四個 OpenSpec skill 定義)
- 新增 .claude/commands/opsx/*.md(四個 opsx command 定義)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:22:39 +08:00
a620906209 ecfb268d61 Merge pull request 'ci: 新增 Gitea Actions 自動部署 workflow' (#7) from chore/remove-unused-workflows into master
Deploy Production / deploy (push) Has been cancelled
Reviewed-on: #7
2026-05-25 09:43:03 +00:00
a620906209 d4821e527b chore: 刪除未使用的 GitHub Actions 範本 workflow
pull requests / uneditable (pull_request_target) Failing after 1s
這些 Laravel 框架預設 workflow 被 Gitea runner 誤接,
導致大量殭屍容器佔用伺服器資源,目前專案也未實作測試

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:42:18 +08:00
a620906209 75462c2619 ci: 新增 Gitea Actions 自動部署 workflow
push master 時自動執行:git pull、composer install、migrate、
config cache、重啟 queue worker;前端有變動才重建 image

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:03:01 +08:00
a620906209 12bd854dce Merge pull request 'chore:調整DB對內port' (#6) from developer into master
Tests / PHP 8.2 (push) Failing after 1s
Tests / PHP 8.3 (push) Failing after 1s
Reviewed-on: #6
2026-05-25 08:50:41 +00:00
a620906209 d6fdfb07f0 chore:調整DB對內port
Tests / PHP 8.2 (pull_request) Failing after 1s
Tests / PHP 8.3 (pull_request) Failing after 1s
pull requests / uneditable (pull_request_target) Failing after 1s
2026-05-25 16:49:12 +08:00
a620906209 250c50fc74 Merge pull request 'chore: 清除廢棄的 DB 表與 Model' (#5) from chore/db-cleanup into master
Tests / PHP 8.2 (push) Failing after 2s
Tests / PHP 8.3 (push) Failing after 1s
Reviewed-on: #5
2026-05-25 08:37:55 +00:00
a620906209 ff2484a48f chore: 清除廢棄的 DB 表與 Model
pull requests / uneditable (pull_request_target) Failing after 1s
Tests / PHP 8.2 (pull_request) Has been cancelled
Tests / PHP 8.3 (pull_request) Has been cancelled
- 新增 migration 刪除 coach_profiles、coach_member、plans、subscriptions 四張廢棄表
- 刪除對應的空殼 Model:CoachProfile、Plan、Subscription

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:34:04 +08:00
a620906209 856c342757 Merge pull request 'feat: 新增 DemoSeeder — 台灣潛水平台展示資料' (#4) from feat/demo-seeder into master
Tests / PHP 8.2 (push) Failing after 2s
Tests / PHP 8.3 (push) Failing after 3s
Reviewed-on: #4
2026-05-24 20:39:31 +00:00
a620906209 56dbca2e11 feat: 新增 DemoSeeder — 台灣潛水平台展示資料
Tests / PHP 8.2 (pull_request) Failing after 3s
Tests / PHP 8.3 (pull_request) Failing after 2s
pull requests / uneditable (pull_request_target) Failing after 1s
包含 4 教練(墾丁/綠島/小琉球/北海岸)、8 會員、12 課程、
24 時段、24 預約(已完成+待確認)、10 則評價與通知。
首次執行後自動跳過(冪等保護)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 04:37:39 +08:00
a620906209 0814a3196f Merge pull request #2 from a620906209/feature/swagger-api-docs
Feature/swagger api docs
2026-05-25 02:35:37 +08:00
a620906209 0db38ee30d Merge pull request #1 from a620906209/docs/claude-md
docs:新增 CLAUDE.MD 技術與開發規範文件
2026-05-25 02:34:48 +08:00
a620906209 8c54808cd8 Merge pull request 'docs:新增 CLAUDE.MD 技術與開發規範文件' (#2) from docs/claude-md into master
Tests / PHP 8.2 (push) Failing after 2s
Tests / PHP 8.3 (push) Failing after 2s
Reviewed-on: #2
2026-05-24 18:27:41 +00:00
a620906209 3ddad32129 Merge pull request 'chore:新增 swagger-api-docs-completion change 規劃' (#3) from feature/swagger-api-docs into master
Tests / PHP 8.2 (push) Failing after 2s
Tests / PHP 8.3 (push) Failing after 2s
Reviewed-on: #3
2026-05-24 18:26:58 +00:00
a620906209 0dda2bb2a3 docs:補全所有 API 端點 Swagger 文件(73 個端點)
Tests / PHP 8.2 (pull_request) Failing after 2s
Tests / PHP 8.3 (pull_request) Failing after 2s
- 修正 AuthApiDoc.php 路徑錯位(/register/member → /member/register 等)
  及 change-password 方法(Post→Put);補上 POST /logout、GET /user
- 新增 AuthSupplementDoc.php(Google OAuth 2 端點)
- 新增 PublicApiDoc.php(共用 Schema + 公開課程 4 端點)
- 新增 MemberApiDoc.php(預約、評價、通知共 13 端點)
- 新增 ProviderApiDoc.php(課程、圖片、時段、預約管理共 18 端點)
- 新增 AdminApiDoc.php(統計、會員/教練/課程管理共 16 端點)
- 更新 README.md
- 歸檔 swagger-api-docs-completion change,同步主規格

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:24:50 +00:00
a620906209 b475a61d14 chore:新增 swagger-api-docs-completion change 規劃
proposal / design / specs / tasks 全部就緒,21 tasks 待實作

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:24:50 +00:00
a620906209 4aab94e07a docs:新增 CLAUDE.MD 技術與開發規範文件
Tests / PHP 8.2 (pull_request) Failing after 2s
Tests / PHP 8.3 (pull_request) Failing after 2s
pull requests / uneditable (pull_request_target) Failing after 0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:19:24 +08:00
a620906209 78bf145f37 security:移除 docker-compose.yml 硬編碼密碼,改用環境變數
Tests / PHP 8.2 (push) Failing after 2s
Tests / PHP 8.3 (push) Failing after 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:37:43 +08:00
a620906209 a3462f8493 security:移除 docker-compose.yml 硬編碼密碼,改用環境變數
Tests / PHP 8.2 (pull_request) Failing after 2s
Tests / PHP 8.3 (pull_request) Failing after 2s
pull requests / uneditable (pull_request_target) Failing after 0s
- DB_PASSWORD、MYSQL_ROOT_PASSWORD 改由 .env 注入
- MySQL healthcheck 移除密碼參數(改用無認證 ping)
- .env.example 補上 MYSQL_ROOT_PASSWORD 說明
- 已用 git filter-repo 清除歷史中的硬編碼密碼

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:30:54 +08:00
a620906209 05ef8b9316 docs:補全所有 API 端點 Swagger 文件(73 個端點)
- 修正 AuthApiDoc.php 路徑錯位(/register/member → /member/register 等)
  及 change-password 方法(Post→Put);補上 POST /logout、GET /user
- 新增 AuthSupplementDoc.php(Google OAuth 2 端點)
- 新增 PublicApiDoc.php(共用 Schema + 公開課程 4 端點)
- 新增 MemberApiDoc.php(預約、評價、通知共 13 端點)
- 新增 ProviderApiDoc.php(課程、圖片、時段、預約管理共 18 端點)
- 新增 AdminApiDoc.php(統計、會員/教練/課程管理共 16 端點)
- 更新 README.md
- 歸檔 swagger-api-docs-completion change,同步主規格

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:17:25 +08:00
a620906209 95bafef52d chore:新增 swagger-api-docs-completion change 規劃
proposal / design / specs / tasks 全部就緒,21 tasks 待實作

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:15 +08:00
a620906209 bda6533105 chore:歸檔 api-performance-optimization,同步主規格
- 移至 openspec/changes/archive/2026-05-25-api-performance-optimization/
- 新增主規格:api-cache-layer、db-index-optimization
- 更新 review-lifecycle 規格(補分頁、eager load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:08 +08:00
a620906209 efb1f22be3 feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
- 引入 Redis(predis)快取層:Admin Stats(5分鐘)、課程列表(3分鐘,tag-based 失效)、評價分布(10分鐘)
- ReviewController::publicList 改為 paginate + eager load votes,消除 N+1
- AdminReviewController::index 加入分頁(預設 20,最大 100)
- 新增 notifications / diving_offers 效能索引 migration
- 新增 docker-compose.override.yml 本機開發 port mapping 機制(不進 git)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:16 +08:00
a620906209 915e404dfc fix:修正 Email 設定與規格同步
- .env.example:MAIL_MAILER 改為 smtp(對應 Mailpit 本地測試流程)
- notification-email spec:移除 ReviewReceived Email 觸發(僅 database channel)
- notification-email spec:Email CTA 連結改為 /my-bookings(移除 /{id})

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 23:21:16 +08:00
a620906209 0bce40a5bf 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
a620906209 ca2ed14de3 feat:實作課程圖片上傳 — 封面 + 相簿管理
後端:
- Migration:diving_offers 新增 cover_image 欄位、新增 course_images 表(含索引)
- CourseImage Model(CREATED_AT、url accessor)
- DivingOffer:cover_image_url accessor、hasMany courseImages、static::deleting() 孤兒清理
- CourseImageController:封面上傳/刪除、相簿上傳(max 3)/刪除,統一 mimes+size 驗證
- DivingOfferController:index/show 回傳加入 cover_image_url 與 images 陣列
- 修正 APP_URL 加入 port(:8080),Storage::url() 才能產生正確圖片連結

前端:
- courseImageApi.js:uploadCover/deleteCover/uploadImage/deleteImage
- CourseCard:有封面顯示 <img>,無封面顯示漸層佔位
- CourseDetailView:封面大圖 + 相簿縮圖橫列(點擊開新分頁)
- OfferFormView(編輯模式):封面預覽/更換/刪除、相簿縮圖管理(達 3 張隱藏上傳按鈕)

基礎設施:
- docker-entrypoint.sh:加入 storage:link --force
- docker-compose.yml:移除 storage-data named volume(改用 bind mount,避免 Nginx 讀不到圖片)

測試:
- CourseImageTest.php:14 個 Feature Test 全部 PASS(Storage::fake)
  涵蓋:上傳成功/格式驗證/大小驗證/所有權、刪除/無封面不報錯、
        相簿上限/sort_order 遞增、孤兒清理

OpenSpec:
- course-images change 歸檔至 archive/2026-05-12-course-images
- 新增 specs/course-image-upload 主規格(含 bind mount 持久化說明)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 03:54:45 +08:00
a620906209 8ef293332a feat:實作評價系統 — 匿名評價、有幫助投票、手動完成預約
後端:
- 新增 reviews / review_edits / review_votes migration(含索引)
- Review / ReviewEdit / ReviewVote Model
- ReviewController:評價 CRUD、資格驗證(completed booking)、rating 即時重算
- toggleHelpful:Member 限定、GREATEST 原子防負、DB transaction 同步
- AdminReviewController:全量列表、刪除(含重算)
- AdminBookingController:全量列表、手動標記 completed
- ProviderBookingController 新增 complete 方法(教練手動完成預約)
- DevelopmentSeeder:快速重建測試資料(admin/coach/member + offers + bookings)
- EnsureAdmin middleware 正式納入 bootstrap/app.php
- Nginx server_name 加入 cfdive.local

前端:
- 課程詳情頁加入評價區塊(星等分布、排序切換、撰寫/修改/刪除、有幫助 Toggle)
- Coach Portal 新增「課程評價」頁(只讀,依課程分組)
- Coach 預約管理加入「完成」按鈕
- Admin 新增「預約管理」頁(標記完成)、「評價管理」頁(刪除)
- Admin / Coach Navbar 新增對應連結

OpenSpec:
- review-system change 歸檔至 archive/2026-05-12-review-system
- 新增 specs/review-lifecycle 與 specs/review-voting 主規格
- review-voting spec 補充 Member 限定與 GREATEST 原子更新說明

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 02:46:54 +08:00
a620906209 88c0c09ccb feat:實作預約系統 — 時段管理、預約生命週期與前端整合
後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 00:24:51 +08:00
346 changed files with 30397 additions and 783 deletions
+152
View File
@@ -0,0 +1,152 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]``- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+157
View File
@@ -0,0 +1,157 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+173
View File
@@ -0,0 +1,173 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+106
View File
@@ -0,0 +1,106 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]``- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+288
View File
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+110
View File
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+31 -9
View File
@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_URL=http://localhost APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -14,8 +15,7 @@ APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stderr
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
@@ -25,28 +25,29 @@ DB_CONNECTION=sqlite
# DB_DATABASE=laravel # DB_DATABASE=laravel
# DB_USERNAME=root # DB_USERNAME=root
# DB_PASSWORD= # DB_PASSWORD=
# MYSQL_ROOT_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案)
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
CACHE_STORE=database CACHE_STORE=redis
CACHE_PREFIX= CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
MAIL_PORT=2525 MAIL_PORT=2525
MAIL_USERNAME=null MAIL_USERNAME=null
@@ -62,3 +63,24 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
SENTRY_LARAVEL_DSN=
SENTRY_ENVIRONMENT="${APP_ENV}"
REVERB_APP_ID=
REVERB_APP_KEY=
REVERB_APP_SECRET=
REVERB_HOST=localhost
REVERB_PORT=8085
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Swagger UI — API 伺服器 Base URL(結尾不加斜線)
# 本地:http://localhost:8080/api
# 生產:https://api.hank-space.com/api
L5_SWAGGER_CONST_HOST=http://localhost:8080/api
L5_SWAGGER_GENERATE_ALWAYS=false
+52
View File
@@ -0,0 +1,52 @@
name: Deploy Production
on:
push:
branches:
- master
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Pull latest code
run: |
cd /root/myproject/CFDivePlatform
flock /tmp/cfdive-deploy.lock git fetch origin
git reset --hard origin/master
- name: Install Composer dependencies
run: |
cd /root/myproject/CFDivePlatform
docker compose exec -T app composer install --no-dev --optimize-autoloader
- name: Run migrations
run: |
cd /root/myproject/CFDivePlatform
docker compose exec -T app php artisan migrate --force
- name: Cache configs
run: |
cd /root/myproject/CFDivePlatform
docker compose exec -T app php artisan config:cache
docker compose exec -T app php artisan route:cache
docker compose exec -T app php artisan view:cache
docker compose exec -T app php artisan event:cache
- name: Generate Swagger docs
run: |
cd /root/myproject/CFDivePlatform
docker compose exec -T app php artisan l5-swagger:generate
- name: Restart queue worker
run: |
cd /root/myproject/CFDivePlatform
docker compose restart queue-worker
- name: Rebuild frontend (if changed)
run: |
cd /root/myproject/CFDivePlatform
if git diff --name-only HEAD~1 HEAD | grep -q '^frontend/'; then
docker compose build frontend
docker compose up -d frontend
fi
+38
View File
@@ -0,0 +1,38 @@
name: Run Tests
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
run: |
apk add --no-cache \
php84 php84-phar php84-mbstring php84-dom php84-fileinfo php84-gd \
php84-pdo php84-pdo_sqlite php84-sqlite3 php84-tokenizer \
php84-xml php84-xmlwriter php84-ctype php84-curl php84-openssl \
php84-json php84-session php84-iconv \
curl unzip
ln -sf /usr/bin/php84 /usr/local/bin/php
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- name: Copy .env
run: cp .env.example .env
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Generate application key
run: php artisan key:generate
- name: Run tests
run: php artisan test
-12
View File
@@ -1,12 +0,0 @@
name: issues
on:
issues:
types: [labeled]
permissions:
issues: write
jobs:
help-wanted:
uses: laravel/.github/.github/workflows/issues.yml@main
-12
View File
@@ -1,12 +0,0 @@
name: pull requests
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
uneditable:
uses: laravel/.github/.github/workflows/pull-requests.yml@main
-47
View File
@@ -1,47 +0,0 @@
name: Tests
on:
push:
branches:
- master
- '*.x'
pull_request:
schedule:
- cron: '0 0 * * *'
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3]
name: PHP ${{ matrix.php }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: none
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Copy environment file
run: cp .env.example .env
- name: Generate app key
run: php artisan key:generate
- name: Execute tests
run: vendor/bin/phpunit
-13
View File
@@ -1,13 +0,0 @@
name: update changelog
on:
release:
types: [released]
permissions: {}
jobs:
update:
permissions:
contents: write
uses: laravel/.github/.github/workflows/update-changelog.yml@main
+4 -1
View File
@@ -1,4 +1,5 @@
/.phpunit.cache /.phpunit.cache
docker-compose.override.yml
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot
@@ -17,7 +18,8 @@ yarn-error.log
/.fleet /.fleet
/.idea /.idea
/.vscode /.vscode
/.claude /.claude/settings.local.json
/.claude/worktrees/
/.opensp /.opensp
# Frontend # Frontend
@@ -25,3 +27,4 @@ yarn-error.log
/frontend/dist /frontend/dist
/frontend/.env /frontend/.env
@@ -0,0 +1,26 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
## Before writing any code
Stop at the first rung that holds:
- Does this need to be built at all? (YAGNI)
- Does the standard library already do this? Use it.
- Does a native platform feature cover it? Use it.
- Does an already-installed dependency solve it? Use it.
- Can this be one line? Make it one line.
- Only then: write the minimum code that works.
## Rules
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a ponytail: comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.MD — 全域開發規則
## 語言設定
所有回應、說明、註解一律使用**繁體中文**。
程式碼內的變數名稱、函式名稱維持英文。
---
## 技術棧預設
- **後端**PHP 8.x / Laravel 11.44.7(主力)
- **前端**Vue 3 / React
- **DB**MySQL 8.0port 3306,透過 Docker 容器運行)
- **IDE**Windsurf
---
## 禁止行為
- 禁止在未確認 spec 對應關係的情況下開始實作
- 禁止使用 raw SQL query(除非有明確說明理由)
- 禁止在前端直接使用 `innerHTML` 渲染未經處理的使用者輸入
---
## 安全強制規則(涉及以下情境時必須執行)
**觸發條件**:task 涉及以下任一項時,實作前必須先回答所有問題,未回答完畢不得開始實作。
### 觸發條件清單
- 接受外部輸入(表單、API 參數、URL query
- 資料庫讀寫操作
- 身份驗證 / 授權邏輯
- 檔案上傳或處理
- 呼叫外部 API / Webhook
### 安全確認問題(觸發後必答)
**輸入驗證**
- 外部輸入有沒有經過 Validation?在哪一層?
- 有沒有直接拼接進 SQL 或 shell 指令?
**資料庫**
- 是否使用 Query Builder 或 Eloquent ORM?(禁止使用 raw query,除非有明確理由)
- 有沒有 N+1 查詢風險?
**權限**
- 這個操作需要什麼權限?權限檢查在哪一層執行?
- 有沒有可能被未授權使用者觸發?
**前端(Vue/React**
- 有沒有直接將使用者輸入渲染進 DOM?(XSS 風險)
- API 呼叫有沒有帶正確的 Auth Header
### 機敏資訊管理
- 密碼、API Key 等一律放 `.env`,不進版本控制
- 設定檔使用 `${VAR}` 引用環境變數,實際值由 `.env` 注入
---
## 命名原則
- Function 名必須是動詞片語,且能完整描述其行為(`getUserById` 而非 `getUser`
- Boolean 變數 / 屬性以 `is` / `has` / `can` 開頭(`isActive``hasPermission`
- 禁止使用縮寫(`usr``btn``tmp`),除非是業界通用(`id``url``api`
- Class 名是名詞,不帶 `Manager` / `Helper` / `Utils` 等空洞字尾——若需要,代表職責未釐清
---
## Function 設計原則
- 單一職責:一個 function 只做一件事,能用一句話描述
- 長度警戒線:超過 20 行必須說明理由,或主動提示拆分
- 參數上限:超過 3 個參數時,考慮封裝為 DTOData Transfer Object
---
## 註解原則
- 好的命名取代大部分的註解——若需要大量解釋,先重構命名
- 禁止「描述行為」的註解(`// 遍歷陣列`),只允許「解釋為什麼」的註解(`// 此處使用 X 而非 Y,因為...`
- TODO 必須帶 issue 連結或日期,裸露的 TODO 視為技術債
---
## SOLID 原則
| 原則 | 實踐方式 |
|------|---------|
| **S** 單一職責 | 每個 Controller 只處理一個資源;業務邏輯抽到 Service |
| **O** 開放封閉 | 新增功能透過擴充(新 Class / 新方法),不修改既有邏輯 |
| **L** 里氏替換 | 子類別可完全替換父類別,不破壞預期行為 |
| **I** 介面隔離 | 不強迫實作用不到的方法;介面依使用情境拆細 |
| **D** 依賴反轉 | 依賴抽象(介面 / Contract)而非具體實作;透過 Laravel DI Container 注入 |
### OOP 規範
- **封裝**:內部狀態不直接暴露,透過方法存取
- **繼承**:優先組合(Trait / Composition)而非繼承;繼承層數不超過 2 層
- **多型**:相同介面的不同實作透過 binding 切換,不用 `if/switch` 判斷型別
---
## OpenSpec 工作流規則
### Task 執行前
每次執行 task 前,先確認:
1. 這個 task 對應哪一條 spec?(必須能指向具體文件與段落)
2. Acceptance Criteria 是否已定義且可測量?
3. 這個 task 完成後,哪些 spec 文件需要同步更新?
### Task 執行後
完成任何 task 後,主動告知:
- 實作結果是否與 spec 描述一致
- 如有偏差,列出偏差點並詢問是否更新 spec
- **必須告知 spec 同步狀態**:這個改動有沒有影響現有 spec 描述的行為?需要更新哪個文件?
---
## 維護性規則(所有 task 適用,非強制阻擋,但必須提醒)
每次 task 完成後,主動告知以下項目的狀態:
- **重複邏輯**:這段邏輯是否已存在於 codebase 中,應該抽共用?
- **命名一致性**:變數、函式、API endpoint 命名是否與現有規範一致?
---
## 溝通風格
- 發現問題直接說,不要繞彎
- 有不確定的地方,明確標注「我不確定,需要你確認」
- 不要重複確認已經決定的技術選擇
+5 -1
View File
@@ -7,6 +7,9 @@ RUN apt-get update && apt-get install -y \
git \ git \
curl \ curl \
libpng-dev \ libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libwebp-dev \
libonig-dev \ libonig-dev \
libxml2-dev \ libxml2-dev \
zip \ zip \
@@ -20,7 +23,8 @@ RUN apt-get update && apt-get install -y \
RUN apt-get clean && rm -rf /var/lib/apt/lists/* RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# 安裝 PHP 擴展 # 安裝 PHP 擴展
# 這些擴展是 Laravel 和一般 PHP 開發所需的 # GD 需要在 install 前先 configure,才能帶入 jpeg/webp/freetype 支援
RUN docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
# 從官方 Composer 鏡像複製 Composer 執行文件 # 從官方 Composer 鏡像複製 Composer 執行文件
+96 -3
View File
@@ -1,4 +1,97 @@
![image](https://github.com/user-attachments/assets/332bf381-8faa-441f-8843-bff2c1fbde7a) # CFDive Platform
![image](https://github.com/user-attachments/assets/f0a765f5-5386-467e-80b8-4a5ef8ef0bcf)
![image](https://github.com/user-attachments/assets/5d6ad6ee-f100-4d45-9200-d61ce76ca7c4)
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、即時訊息、評價與通知等完整服務。
---
## 功能概覽
**會員(Member**
- 註冊 / 登入(Email + Google OAuth
- Token 自動續期(401 refresh-then-retrysessionStorage 儲存)
- 瀏覽、搜尋、篩選潛水課程
- 查看課程時段並送出預約
- 與教練即時訊息(文字 + 圖片,含已讀回執)
- 對完成的課程留下評價(支援匿名、有幫助投票)
- 站內通知(Bell Icon 即時更新 + 瀏覽器推播)
**教練(Provider**
- 課程 CRUD(含封面 + 相簿圖片上傳,伺服器端壓縮)
- 課程時段管理
- 預約管理(確認 / 拒絕 / 完成 / 取消)
- 與學員即時訊息
- 證照上傳與教練資格送審
**管理員(Admin**
- 平台統計數據(會員數、教練數、課程數)
- 會員與教練帳號管理(啟用 / 停用)
- 教練資格審核(送審 / 通過 / 駁回 / 撤銷)
- 課程、預約、評價管理
---
## 技術棧
| 層級 | 技術 |
|------|------|
| 後端 | PHP 8.x / Laravel 11 |
| 前端 | Vue 3 + Vite + Tailwind CSS |
| 資料庫 | MySQL 8.0 |
| 快取 | Redis |
| 即時通訊 | Laravel ReverbWebSocket`wss://ws.hank-space.com`|
| 認證 | Laravel Sanctum + Google OAuth + Token Refresh |
| 容器 | Docker / Docker Compose |
| API 文件 | Swagger UIl5-swagger|
| 錯誤監控 | Sentry |
| CI/CD | Gitea Actions(自動部署至 VPS|
| 測試 | PHPUnit / Laravel Feature & Unit Tests |
---
## API 文件
Swagger UI(由 l5-swagger 生成)涵蓋所有端點,包含:
- 認證(Email + Google OAuth)、Token 刷新
- 公開課程查詢
- 會員預約 / 即時訊息 / 評價 / 通知
- 教練課程 / 時段 / 預約管理 / 資格驗證申請(證照送審)
- 管理員後台
| 環境 | Swagger UI |
|------|-----------|
| 本地 | http://localhost:8080/api/documentation |
| 生產 | https://api.hank-space.com/api/documentation |
---
## 健康檢查
```
GET /health
```
回傳 DB、Redis、Cache 狀態,供 UptimeRobot 監控使用。全部正常回 200,任一異常回 503。
---
## 本地開發
**測試用帳號**
| 角色 | 帳號 | 密碼 | 用途 |
|------|------|------|------|
| 會員 | Guest@cfdive.com | guestpassword | 體驗課程瀏覽、預約、會員預約紀錄 |
| 教練 | Guest_Coach@cfdive.com | coachpassword | 體驗課程管理、時段管理、預約管理 |
**教練頁入口**
目前首頁尚未提供明顯導引到教練後台的入口。若要體驗教練功能,請直接開啟 `/coach/login`,使用上方教練試用帳號登入後會進入 `/coach/dashboard`
| 服務 | 本地 | 生產 |
|------|------|------|
| 前端 | http://localhost:8080 | https://app.hank-space.com |
| API | http://localhost:8080/api | https://api.hank-space.com/api |
| Swagger UI | http://localhost:8080/api/documentation | https://api.hank-space.com/api/documentation |
| phpMyAdmin | http://localhost:8081 | http://\<vps-ip\>:8081 |
| Mailpit | http://localhost:8025 | http://\<vps-ip\>:8025 |
| Reverb WebSocket | ws://localhost:8085 | wss://ws.hank-space.com |
@@ -0,0 +1,33 @@
<?php
namespace App\Broadcasting;
use App\Models\Booking;
use App\Models\User;
class BookingPresenceChannel
{
public function join(User $user, Booking $booking): array|false
{
if ($booking->status->value !== 'confirmed') {
return false;
}
$booking->loadMissing('schedule');
$isMember = $user->role === 'member' && $booking->member_id === $user->id;
$isProvider = $user->role === 'provider'
&& $booking->schedule !== null
&& $booking->schedule->provider_id === $user->id;
if (!$isMember && !$isProvider) {
return false;
}
return [
'user_id' => $user->id,
'user_type' => $user->role,
'name' => $user->name,
];
}
}
@@ -0,0 +1,38 @@
<?php
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;
class CompleteFinishedBookings extends Command
{
protected $signature = 'app:complete-finished-bookings';
protected $description = '將課程日期已過的 confirmed 預約標記為 completed';
public function handle(): void
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->where('status', BookingStatus::Confirmed->value)
->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
->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.");
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\AdminProfile;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class CreateAdminUser extends Command
{
protected $signature = 'app:create-admin
{name : 管理員姓名}
{email : 登入用電子郵件}
{--password= : 密碼(至少 8 碼,未提供時互動式輸入)}
{--position= : 職位}
{--department= : 部門}';
protected $description = '建立管理員帳號(公開 /api/admin/register 端點已移除,管理員一律由主機端建立)';
public function handle(): int
{
$password = $this->option('password') ?: $this->secret('請輸入密碼(至少 8 碼)');
$validator = Validator::make([
'name' => $this->argument('name'),
'email' => $this->argument('email'),
'password' => $password,
], [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
// 管理權限影響全平台,密碼門檻高於一般使用者的 min:6
'password' => 'required|string|min:8',
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
$user = User::create([
'name' => $this->argument('name'),
'email' => $this->argument('email'),
'password' => Hash::make($password),
'role' => 'admin',
]);
AdminProfile::create([
'user_id' => $user->id,
'position' => $this->option('position'),
'department' => $this->option('department'),
]);
$this->info("管理員帳號已建立:{$user->email}id={$user->id}");
return self::SUCCESS;
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Enums\BookingStatus;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ExpirePendingBookings extends Command
{
protected $signature = 'app:expire-pending-bookings';
protected $description = '將超過 48 小時未確認的 pending 預約標記為 expired';
public function handle(): void
{
$count = Booking::where('status', BookingStatus::Pending->value)
->where('created_at', '<=', now()->subHours(48))
->update(['status' => BookingStatus::Expired->value]);
Log::info("ExpirePendingBookings: {$count} expired");
$this->info("ExpirePendingBookings: {$count} bookings expired.");
}
}
+559
View File
@@ -0,0 +1,559 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="Admin 統計",
* description="管理員平台統計"
* )
* @OA\Tag(
* name="Admin 會員管理",
* description="管理員的會員帳號管理"
* )
* @OA\Tag(
* name="Admin 教練管理",
* description="管理員的服務提供者帳號管理"
* )
* @OA\Tag(
* name="Admin 課程管理",
* description="管理員的課程、預約、評價管理"
* )
*/
class AdminApiDoc
{
// -----------------------------------------------------------------------
// Admin Stats
// -----------------------------------------------------------------------
/**
* 取得平台統計數據
*
* @OA\Get(
* path="/admin/stats",
* summary="取得平台統計數據",
* description="回傳會員總數、服務提供者總數、課程總數;非 admin 角色回傳 403",
* operationId="getAdminStats",
* tags={"Admin 統計"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="total_members", type="integer", example=128),
* @OA\Property(property="total_providers", type="integer", example=34),
* @OA\Property(property="total_offers", type="integer", example=87)
* )
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminStats()
{
}
// -----------------------------------------------------------------------
// Admin Member Management
// -----------------------------------------------------------------------
/**
* 取得會員列表
*
* @OA\Get(
* path="/admin/members",
* summary="取得會員列表",
* description="分頁回傳所有會員帳號,含 member_profile",
* operationId="listAdminMembers",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="member@example.com"),
* @OA\Property(property="role", type="string", example="member"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="member_profile", type="object", nullable=true)
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminMembers()
{
}
/**
* 取得單一會員
*
* @OA\Get(
* path="/admin/members/{id}",
* summary="取得單一會員",
* operationId="getAdminMember",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminMember()
{
}
/**
* 切換會員啟用狀態
*
* @OA\Put(
* path="/admin/members/{id}/toggle-active",
* summary="切換會員啟用狀態",
* description="啟用或停用指定會員帳號",
* operationId="toggleMemberActive",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="帳號已停用"),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleMemberActive()
{
}
/**
* 確認會員存在
*
* @OA\Get(
* path="/admin/check-member/{id}",
* summary="確認會員存在",
* description="快速確認指定 ID 的會員是否存在(角色為 member)",
* operationId="checkMember",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="會員存在",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="exists", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function checkMember()
{
}
// -----------------------------------------------------------------------
// Admin Provider Management
// -----------------------------------------------------------------------
/**
* 取得教練列表
*
* @OA\Get(
* path="/admin/providers",
* summary="取得教練列表",
* description="分頁回傳所有服務提供者,含 provider_profile",
* operationId="listAdminProviders",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=2),
* @OA\Property(property="name", type="string", example="林教練"),
* @OA\Property(property="email", type="string", example="coach@example.com"),
* @OA\Property(property="role", type="string", example="provider"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="is_verified", type="boolean", example=false),
* @OA\Property(property="provider_profile", type="object", nullable=true)
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminProviders()
{
}
/**
* 取得單一教練
*
* @OA\Get(
* path="/admin/providers/{id}",
* summary="取得單一教練",
* operationId="getAdminProvider",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminProvider()
{
}
/**
* 切換教練啟用狀態
*
* @OA\Put(
* path="/admin/providers/{id}/toggle-active",
* summary="切換教練啟用狀態",
* operationId="toggleProviderActive",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="帳號已停用"),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleProviderActive()
{
}
/**
* 教練審核佇列
*
* @OA\Get(
* path="/admin/verifications",
* summary="教練審核佇列",
* description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
* operationId="adminVerificationIndex",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="status", in="query", required=false, description="unsubmitted / pending / approved / rejected / all", @OA\Schema(type="string", default="pending")),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(
* @OA\Property(property="user_id", type="integer", example=5),
* @OA\Property(property="name", type="string", example="王教練"),
* @OA\Property(property="email", type="string", example="coach@example.com"),
* @OA\Property(property="business_name", type="string", example="藍海潛水"),
* @OA\Property(property="verification_status", type="string", example="pending"),
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
* @OA\Property(property="certifications", type="array", @OA\Items(
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
* ))
* ))
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function adminVerificationIndex()
{
}
/**
* 通過教練審核
*
* @OA\Put(
* path="/admin/verifications/{userId}/approve",
* summary="通過教練審核",
* description="將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
* operationId="adminVerificationApprove",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(response=200, description="已通過審核"),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="當前狀態無法通過審核", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function adminVerificationApprove()
{
}
/**
* 駁回教練審核
*
* @OA\Put(
* path="/admin/verifications/{userId}/reject",
* summary="駁回教練審核",
* description="駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
* operationId="adminVerificationReject",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(required=true, @OA\JsonContent(
* required={"reason"},
* @OA\Property(property="reason", type="string", maxLength=500, example="證照影像不清晰,請重新拍攝上傳")
* )),
* @OA\Response(response=200, description="已駁回"),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="原因未填或狀態不可駁回", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function adminVerificationReject()
{
}
/**
* 確認教練存在
*
* @OA\Get(
* path="/admin/check-provider/{id}",
* summary="確認教練存在",
* operationId="checkProvider",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="exists", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function checkProvider()
{
}
// -----------------------------------------------------------------------
// Admin Offers / Bookings / Reviews
// -----------------------------------------------------------------------
/**
* 取得所有課程(Admin
*
* @OA\Get(
* path="/admin/offers",
* summary="取得所有課程(Admin",
* description="分頁回傳全平台課程列表",
* operationId="listAdminOffers",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminOffers()
{
}
/**
* 刪除課程(Admin
*
* @OA\Delete(
* path="/admin/offers/{id}",
* summary="刪除課程(Admin",
* operationId="deleteAdminOffer",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程已刪除")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteAdminOffer()
{
}
/**
* 取得所有預約(Admin
*
* @OA\Get(
* path="/admin/bookings",
* summary="取得所有預約(Admin",
* description="分頁回傳全平台預約列表",
* operationId="listAdminBookings",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminBookings()
{
}
/**
* 標記預約完成(Admin
*
* @OA\Put(
* path="/admin/bookings/{id}/complete",
* summary="標記預約完成(Admin",
* operationId="completeBookingByAdmin",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已完成"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function completeBookingByAdmin()
{
}
/**
* 取得所有評價(Admin
*
* @OA\Get(
* path="/admin/reviews",
* summary="取得所有評價(Admin",
* description="分頁回傳全平台評價列表,per_page 最大 100",
* operationId="listAdminReviews",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(最大 100", @OA\Schema(type="integer", default=20, maximum=100)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Review")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminReviews()
{
}
/**
* 刪除評價(Admin
*
* @OA\Delete(
* path="/admin/reviews/{id}",
* summary="刪除評價(Admin",
* operationId="deleteAdminReview",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已刪除")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteAdminReview()
{
}
}
+188 -73
View File
@@ -12,7 +12,7 @@ use OpenApi\Annotations as OA;
* ) * )
* *
* @OA\Server( * @OA\Server(
* url="/api", * url=L5_SWAGGER_CONST_HOST,
* description="API 伺服器" * description="API 伺服器"
* ) * )
* *
@@ -35,6 +35,10 @@ use OpenApi\Annotations as OA;
* name="管理員", * name="管理員",
* description="管理員相關操作" * description="管理員相關操作"
* ) * )
* @OA\Tag(
* name="認證",
* description="通用認證操作(登出、取得當前使用者)"
* )
*/ */
class AuthApiDoc class AuthApiDoc
{ {
@@ -42,7 +46,7 @@ class AuthApiDoc
* 會員註冊 * 會員註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/member", * path="/member/register",
* summary="會員註冊", * summary="會員註冊",
* description="建立新的會員帳號", * description="建立新的會員帳號",
* operationId="registerMember", * operationId="registerMember",
@@ -119,7 +123,7 @@ class AuthApiDoc
* 會員登入 * 會員登入
* *
* @OA\Post( * @OA\Post(
* path="/login/member", * path="/member/login",
* summary="會員登入", * summary="會員登入",
* description="會員帳號登入系統", * description="會員帳號登入系統",
* operationId="loginMember", * operationId="loginMember",
@@ -203,11 +207,43 @@ class AuthApiDoc
{ {
} }
/**
* 會員 Token 刷新
*
* @OA\Post(
* path="/member/refresh",
* summary="會員 Token 刷新",
* description="撤銷當前 Token 並發放新的 Bearer token(不需要重新登入)",
* operationId="refreshMember",
* tags={"會員"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="刷新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="token", type="string", example="24|newtoken..."),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function refreshMember()
{
}
/** /**
* 會員登出 * 會員登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/member", * path="/member/logout",
* summary="會員登出", * summary="會員登出",
* description="會員登出系統並撤銷當前令牌", * description="會員登出系統並撤銷當前令牌",
* operationId="logoutMember", * operationId="logoutMember",
@@ -241,7 +277,7 @@ class AuthApiDoc
* 取得會員個人資料 * 取得會員個人資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/member", * path="/member/profile",
* summary="取得會員個人資料", * summary="取得會員個人資料",
* description="取得當前登入會員的個人資料", * description="取得當前登入會員的個人資料",
* operationId="memberProfile", * operationId="memberProfile",
@@ -303,7 +339,7 @@ class AuthApiDoc
* 更新會員個人資料 * 更新會員個人資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/member", * path="/member/profile",
* summary="更新會員個人資料", * summary="更新會員個人資料",
* description="更新當前登入會員的個人資料", * description="更新當前登入會員的個人資料",
* operationId="updateMemberProfile", * operationId="updateMemberProfile",
@@ -383,8 +419,8 @@ class AuthApiDoc
/** /**
* 修改會員密碼 * 修改會員密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/member", * path="/member/change-password",
* summary="修改會員密碼", * summary="修改會員密碼",
* description="修改當前登入會員的密碼", * description="修改當前登入會員的密碼",
* operationId="changeMemberPassword", * operationId="changeMemberPassword",
@@ -444,7 +480,7 @@ class AuthApiDoc
* 服務提供者註冊 * 服務提供者註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/provider", * path="/provider/register",
* summary="服務提供者註冊", * summary="服務提供者註冊",
* description="建立新的服務提供者帳號", * description="建立新的服務提供者帳號",
* operationId="registerProvider", * operationId="registerProvider",
@@ -498,7 +534,7 @@ class AuthApiDoc
* 服務提供者登入 * 服務提供者登入
* *
* @OA\Post( * @OA\Post(
* path="/login/provider", * path="/provider/login",
* summary="服務提供者登入", * summary="服務提供者登入",
* description="服務提供者帳號登入系統", * description="服務提供者帳號登入系統",
* operationId="loginProvider", * operationId="loginProvider",
@@ -576,11 +612,43 @@ class AuthApiDoc
{ {
} }
/**
* 服務提供者 Token 刷新
*
* @OA\Post(
* path="/provider/refresh",
* summary="服務提供者 Token 刷新",
* description="撤銷當前 Token 並發放新的 Bearer token",
* operationId="refreshProvider",
* tags={"服務提供者"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="刷新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="token", type="string", example="25|newtoken..."),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function refreshProvider()
{
}
/** /**
* 服務提供者登出 * 服務提供者登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/provider", * path="/provider/logout",
* summary="服務提供者登出", * summary="服務提供者登出",
* description="服務提供者登出系統並撤銷當前令牌", * description="服務提供者登出系統並撤銷當前令牌",
* operationId="logoutProvider", * operationId="logoutProvider",
@@ -614,7 +682,7 @@ class AuthApiDoc
* 取得服務提供者資料 * 取得服務提供者資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/provider", * path="/provider/profile",
* summary="取得服務提供者資料", * summary="取得服務提供者資料",
* description="取得當前登入服務提供者的資料", * description="取得當前登入服務提供者的資料",
* operationId="providerProfile", * operationId="providerProfile",
@@ -678,7 +746,7 @@ class AuthApiDoc
* 更新服務提供者資料 * 更新服務提供者資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/provider", * path="/provider/profile",
* summary="更新服務提供者資料", * summary="更新服務提供者資料",
* description="更新當前登入服務提供者的資料", * description="更新當前登入服務提供者的資料",
* operationId="updateProviderProfile", * operationId="updateProviderProfile",
@@ -764,8 +832,8 @@ class AuthApiDoc
/** /**
* 修改服務提供者密碼 * 修改服務提供者密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/provider", * path="/provider/change-password",
* summary="修改服務提供者密碼", * summary="修改服務提供者密碼",
* description="修改當前登入服務提供者的密碼", * description="修改當前登入服務提供者的密碼",
* operationId="changeProviderPassword", * operationId="changeProviderPassword",
@@ -821,63 +889,11 @@ class AuthApiDoc
{ {
} }
/**
* 管理員註冊
*
* @OA\Post(
* path="/register/admin",
* summary="管理員註冊",
* description="建立新的管理員帳號",
* operationId="registerAdmin",
* tags={"管理員"},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"name", "email", "password", "password_confirmation"},
* @OA\Property(property="name", type="string", example="張管理", description="使用者姓名"),
* @OA\Property(property="email", type="string", format="email", example="admin@example.com", description="電子郵件"),
* @OA\Property(property="password", type="string", format="password", example="password123", description="密碼"),
* @OA\Property(property="password_confirmation", type="string", format="password", example="password123", description="確認密碼"),
* @OA\Property(property="phone", type="string", example="0912345678", description="電話號碼"),
* @OA\Property(property="position", type="string", example="系統管理員", description="職位"),
* @OA\Property(property="department", type="string", example="IT部門", description="部門")
* )
* ),
* @OA\Response(
* response=201,
* description="管理員註冊成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="管理員註冊成功"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="user", type="object", ref="#/components/schemas/User"),
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* )
* ),
* @OA\Response(
* response=422,
* description="驗證失敗",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="驗證失敗"),
* @OA\Property(property="errors", type="object")
* )
* )
* )
*/
public function registerAdmin()
{
}
/** /**
* 管理員登入 * 管理員登入
* *
* @OA\Post( * @OA\Post(
* path="/login/admin", * path="/admin/login",
* summary="管理員登入", * summary="管理員登入",
* description="管理員帳號登入系統", * description="管理員帳號登入系統",
* operationId="loginAdmin", * operationId="loginAdmin",
@@ -954,11 +970,43 @@ class AuthApiDoc
{ {
} }
/**
* 管理員 Token 刷新
*
* @OA\Post(
* path="/admin/refresh",
* summary="管理員 Token 刷新",
* description="撤銷當前 Token 並發放新的 Bearer token",
* operationId="refreshAdmin",
* tags={"管理員"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="刷新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="token", type="string", example="26|newtoken..."),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function refreshAdmin()
{
}
/** /**
* 管理員登出 * 管理員登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/admin", * path="/admin/logout",
* summary="管理員登出", * summary="管理員登出",
* description="管理員登出系統並撤銷當前令牌", * description="管理員登出系統並撤銷當前令牌",
* operationId="logoutAdmin", * operationId="logoutAdmin",
@@ -992,7 +1040,7 @@ class AuthApiDoc
* 取得管理員個人資料 * 取得管理員個人資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/admin", * path="/admin/profile",
* summary="取得管理員個人資料", * summary="取得管理員個人資料",
* description="取得當前登入管理員的個人資料", * description="取得當前登入管理員的個人資料",
* operationId="adminProfile", * operationId="adminProfile",
@@ -1030,7 +1078,7 @@ class AuthApiDoc
* 更新管理員個人資料 * 更新管理員個人資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/admin", * path="/admin/profile",
* summary="更新管理員個人資料", * summary="更新管理員個人資料",
* description="更新當前登入管理員的個人資料", * description="更新當前登入管理員的個人資料",
* operationId="updateAdminProfile", * operationId="updateAdminProfile",
@@ -1087,8 +1135,8 @@ class AuthApiDoc
/** /**
* 修改管理員密碼 * 修改管理員密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/admin", * path="/admin/change-password",
* summary="修改管理員密碼", * summary="修改管理員密碼",
* description="修改當前登入管理員的密碼", * description="修改當前登入管理員的密碼",
* operationId="changeAdminPassword", * operationId="changeAdminPassword",
@@ -1143,4 +1191,71 @@ class AuthApiDoc
public function changeAdminPassword() public function changeAdminPassword()
{ {
} }
/**
* 通用登出
*
* @OA\Post(
* path="/logout",
* summary="通用登出",
* description="撤銷當前 Bearer token,適用所有角色",
* operationId="logout",
* tags={"認證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="登出成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="登出成功")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function logout()
{
}
/**
* 取得當前使用者
*
* @OA\Get(
* path="/user",
* summary="取得當前使用者",
* description="回傳當前 Bearer token 所屬的使用者資訊",
* operationId="currentUser",
* tags={"認證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="user@example.com"),
* @OA\Property(property="role", type="string", enum={"member","provider","admin"}, example="member"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function currentUser()
{
}
} }
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="Google OAuth",
* description="Google OAuth 2.0 社群登入"
* )
*/
class AuthSupplementDoc
{
/**
* 取得 Google OAuth 重定向 URL
*
* @OA\Get(
* path="/auth/google/redirect",
* summary="取得 Google OAuth 重定向 URL",
* description="回傳 Google OAuth 授權頁面的 redirect_url,前端應將使用者導向此 URL 以啟動 OAuth 流程",
* operationId="googleRedirect",
* tags={"Google OAuth"},
* @OA\Response(
* response=200,
* description="取得 redirect_url 成功",
* @OA\JsonContent(
* @OA\Property(property="redirect_url", type="string", format="uri", example="https://accounts.google.com/o/oauth2/auth?client_id=...")
* )
* )
* )
*/
public function googleRedirect()
{
}
/**
* Google OAuth 回調
*
* @OA\Get(
* path="/auth/google/callback",
* summary="Google OAuth 回調",
* description="Google OAuth 授權完成後的回調端點,通常由瀏覽器自動呼叫。成功後回傳 Bearer token 與使用者資訊",
* operationId="googleCallback",
* tags={"Google OAuth"},
* @OA\Parameter(
* name="code",
* in="query",
* required=true,
* description="Google 授權碼",
* @OA\Schema(type="string")
* ),
* @OA\Parameter(
* name="state",
* in="query",
* required=false,
* description="OAuth state 參數",
* @OA\Schema(type="string")
* ),
* @OA\Response(
* response=200,
* description="OAuth 登入成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="登入成功"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
* @OA\Property(property="token_type", type="string", example="Bearer"),
* @OA\Property(
* property="user",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="user@gmail.com"),
* @OA\Property(property="role", type="string", example="member"),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* )
* )
* ),
* @OA\Response(
* response=422,
* description="OAuth 驗證失敗",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="OAuth 驗證失敗")
* )
* )
* )
*/
public function googleCallback()
{
}
}
+568
View File
@@ -0,0 +1,568 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="會員預約",
* description="會員的預約管理"
* )
* @OA\Tag(
* name="會員評價",
* description="會員的評價管理"
* )
* @OA\Tag(
* name="通知",
* description="站內通知管理(Member / Provider 共用)"
* )
* @OA\Tag(
* name="即時訊息",
* description="預約聊天室訊息(Member / Provider 共用,僅限 confirmed / completed 預約的參與方)"
* )
*/
class MemberApiDoc
{
// -----------------------------------------------------------------------
// Member Bookings
// -----------------------------------------------------------------------
/**
* 建立預約
*
* @OA\Post(
* path="/member/bookings",
* summary="建立預約",
* description="會員建立新的課程預約",
* operationId="createBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"offer_id","schedule_id","participants"},
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
* )
* ),
* @OA\Response(
* response=201,
* description="預約建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約成功"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(
* response=422,
* description="驗證失敗或時段已滿",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* ),
* @OA\Response(
* response=403,
* description="無權限(非 member 角色)",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function createBooking()
{
}
/**
* 取得我的預約列表
*
* @OA\Get(
* path="/member/bookings",
* summary="取得我的預約列表",
* description="分頁回傳當前會員的所有預約",
* operationId="listMemberBookings",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listMemberBookings()
{
}
/**
* 取得單一預約
*
* @OA\Get(
* path="/member/bookings/{id}",
* summary="取得單一預約",
* operationId="getMemberBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getMemberBooking()
{
}
/**
* 取消預約
*
* @OA\Delete(
* path="/member/bookings/{id}",
* summary="取消預約",
* description="會員取消自己的預約",
* operationId="cancelBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取消成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已取消")
* )
* ),
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function cancelBooking()
{
}
// -----------------------------------------------------------------------
// Member Reviews
// -----------------------------------------------------------------------
/**
* 建立評價
*
* @OA\Post(
* path="/member/reviews",
* summary="建立評價",
* description="會員對已完成的預約課程提交評價",
* operationId="createReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"booking_id","rating"},
* @OA\Property(property="booking_id", type="integer", example=5),
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
* @OA\Property(property="is_anonymous", type="boolean", example=false)
* )
* ),
* @OA\Response(
* response=201,
* description="評價建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已提交"),
* @OA\Property(property="data", ref="#/components/schemas/Review")
* )
* ),
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createReview()
{
}
/**
* 更新評價
*
* @OA\Put(
* path="/member/reviews/{id}",
* summary="更新評價",
* operationId="updateReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
* @OA\Property(property="is_anonymous", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/Review")
* )
* ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateReview()
{
}
/**
* 刪除評價
*
* @OA\Delete(
* path="/member/reviews/{id}",
* summary="刪除評價",
* operationId="deleteReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteReview()
{
}
/**
* 標記評價為有幫助
*
* @OA\Post(
* path="/reviews/{id}/helpful",
* summary="標記評價為有幫助",
* description="切換投票狀態(已投票則撤回)",
* operationId="markReviewHelpful",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="操作成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="helpful_count", type="integer", example=4),
* @OA\Property(property="has_voted", type="boolean", example=true)
* )
* ),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markReviewHelpful()
{
}
// -----------------------------------------------------------------------
// Booking Messages(即時訊息,Member + Provider 共用)
// -----------------------------------------------------------------------
/**
* 取得各預約未讀訊息數
*
* @OA\Get(
* path="/bookings/messages/unread-counts",
* summary="取得各預約未讀訊息數",
* description="回傳當前使用者每個預約中對方發送的未讀訊息數量(Key 為 booking_id",
* operationId="getMessageUnreadCounts",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* description="Key 為 booking_idstring),value 為未讀數(integer",
* example={"12": 3, "17": 1}
* )
* )
* )
* )
*/
public function getMessageUnreadCounts()
{
}
/**
* 取得預約訊息列表
*
* @OA\Get(
* path="/bookings/{booking}/messages",
* summary="取得預約訊息列表",
* description="回傳指定預約的所有訊息(依時間升冪排列),僅限預約參與方存取,且預約狀態需為 confirmed 或 completed",
* operationId="listBookingMessages",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="booking_id", type="integer", example=12),
* @OA\Property(property="sender_type", type="string", enum={"member","provider"}, example="member"),
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
* @OA\Property(property="image_url", type="string", nullable=true, example=null),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-07-01T10:00:00.000000Z")
* )
* )
* )
* ),
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listBookingMessages()
{
}
/**
* 發送預約訊息
*
* @OA\Post(
* path="/bookings/{booking}/messages",
* summary="發送預約訊息",
* description="向指定預約的對方發送文字訊息或圖片(multipart/form-data",
* operationId="sendBookingMessage",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
* @OA\Property(property="image", type="string", format="binary", nullable=true, description="圖片(jpg/pngmessage 與 image 至少擇一)")
* )
* )
* ),
* @OA\Response(
* response=201,
* description="訊息已送出",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="id", type="integer", example=42),
* @OA\Property(property="booking_id", type="integer", example=12),
* @OA\Property(property="sender_type", type="string", example="member"),
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
* @OA\Property(property="image_url", type="string", nullable=true, example=null),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-07-01T10:05:00.000000Z")
* )
* )
* ),
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="message 與 image 皆為空", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function sendBookingMessage()
{
}
/**
* 標記訊息為已讀
*
* @OA\Post(
* path="/bookings/{booking}/messages/read",
* summary="標記訊息為已讀",
* description="將指定預約中對方發送的所有未讀訊息批次標記為已讀",
* operationId="markMessagesRead",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="已標記為已讀")
* )
* ),
* @OA\Response(response=403, description="非參與方", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markMessagesRead()
{
}
// -----------------------------------------------------------------------
// Notifications (Member + Provider 共用)
// -----------------------------------------------------------------------
/**
* 取得通知列表
*
* @OA\Get(
* path="/notifications",
* summary="取得通知列表",
* description="分頁回傳當前使用者的所有通知(Member / Provider 共用)",
* operationId="listNotifications",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="type", type="string", example="booking_confirmed"),
* @OA\Property(property="title", type="string", example="預約已確認"),
* @OA\Property(property="message", type="string", example="您的預約已由教練確認"),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listNotifications()
{
}
/**
* 取得未讀通知數量
*
* @OA\Get(
* path="/notifications/unread-count",
* summary="取得未讀通知數量",
* operationId="getUnreadNotificationCount",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="unread_count", type="integer", example=3)
* )
* )
* )
*/
public function getUnreadNotificationCount()
{
}
/**
* 標記單一通知為已讀
*
* @OA\Patch(
* path="/notifications/{id}/read",
* summary="標記單一通知為已讀",
* operationId="markNotificationRead",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
* )
* ),
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markNotificationRead()
{
}
/**
* 全部通知標記為已讀
*
* @OA\Patch(
* path="/notifications/read-all",
* summary="全部通知標記為已讀",
* operationId="markAllNotificationsRead",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
* )
* )
* )
*/
public function markAllNotificationsRead()
{
}
/**
* 刪除通知
*
* @OA\Delete(
* path="/notifications/{id}",
* summary="刪除通知",
* operationId="deleteNotification",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="通知已刪除")
* )
* ),
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteNotification()
{
}
}
+726
View File
@@ -0,0 +1,726 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="教練課程",
* description="服務提供者的課程管理"
* )
* @OA\Tag(
* name="教練圖片",
* description="服務提供者的課程圖片管理"
* )
* @OA\Tag(
* name="教練時段",
* description="服務提供者的課程時段管理"
* )
* @OA\Tag(
* name="教練預約",
* description="服務提供者的預約管理"
* )
* @OA\Tag(
* name="教練驗證",
* description="服務提供者的資格驗證申請(證照送審)"
* )
*/
class ProviderApiDoc
{
// -----------------------------------------------------------------------
// Provider Verification (證照送審)
// -----------------------------------------------------------------------
/**
* 取得驗證申請狀態
*
* @OA\Get(
* path="/provider/verification",
* summary="取得驗證申請狀態",
* description="回傳當前服務提供者的驗證狀態與已上傳的證照清單",
* operationId="getProviderVerification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="verification_status", type="string", enum={"unsubmitted","pending","approved","rejected"}, example="pending"),
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
* @OA\Property(
* property="certifications",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
* )
* )
* )
* )
* ),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getProviderVerification()
{
}
/**
* 上傳證照圖片
*
* @OA\Post(
* path="/provider/verification/certifications",
* summary="上傳證照圖片",
* description="上傳一張證照圖片(multipart/form-data),未送審前可多次上傳",
* operationId="uploadCertification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"certification"},
* @OA\Property(property="certification", type="string", format="binary", description="證照圖片(jpg/png,最大 5MB")
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
* )
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadCertification()
{
}
/**
* 刪除證照圖片
*
* @OA\Delete(
* path="/provider/verification/certifications/{id}",
* summary="刪除證照圖片",
* description="刪除指定的已上傳證照,已送審(pending/approved)狀態不可刪除",
* operationId="deleteCertification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="證照 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="證照已刪除")
* )
* ),
* @OA\Response(response=403, description="無權限或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteCertification()
{
}
/**
* 送出驗證申請
*
* @OA\Post(
* path="/provider/verification/submit",
* summary="送出驗證申請",
* description="將驗證狀態由 unsubmitted/rejected 轉為 pending,至少需有一張已上傳的證照",
* operationId="submitVerification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="送審成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="驗證申請已送出")
* )
* ),
* @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function submitVerification()
{
}
// -----------------------------------------------------------------------
// Provider Offers CRUD
// -----------------------------------------------------------------------
/**
* 取得自己的課程列表
*
* @OA\Get(
* path="/provider/offers",
* summary="取得自己的課程列表",
* description="回傳當前服務提供者的所有課程(分頁)",
* operationId="listProviderOffers",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listProviderOffers()
{
}
/**
* 建立課程
*
* @OA\Post(
* path="/provider/offers",
* summary="建立課程",
* description="服務提供者建立新的潛水課程",
* operationId="createProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"title","description","region","price","max_participants"},
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
* @OA\Property(property="region", type="string", example="墾丁"),
* @OA\Property(property="price", type="number", format="float", example=8500),
* @OA\Property(property="max_participants", type="integer", example=6),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
* )
* ),
* @OA\Response(
* response=201,
* description="課程建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程建立成功"),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createProviderOffer()
{
}
/**
* 取得單一課程(Provider 視角)
*
* @OA\Get(
* path="/provider/offers/{id}",
* summary="取得單一課程(Provider 視角)",
* description="取得自己的指定課程,非本人課程回傳 403",
* operationId="getProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getProviderOffer()
{
}
/**
* 更新課程
*
* @OA\Put(
* path="/provider/offers/{id}",
* summary="更新課程",
* operationId="updateProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
* @OA\Property(property="description", type="string", example="AOWD 課程"),
* @OA\Property(property="region", type="string", example="小琉球"),
* @OA\Property(property="price", type="number", format="float", example=12000),
* @OA\Property(property="max_participants", type="integer", example=4),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"進階","AOWD"}),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateProviderOffer()
{
}
/**
* 刪除課程
*
* @OA\Delete(
* path="/provider/offers/{id}",
* summary="刪除課程",
* operationId="deleteProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteProviderOffer()
{
}
// -----------------------------------------------------------------------
// Provider Offer Images
// -----------------------------------------------------------------------
/**
* 上傳封面圖片
*
* @OA\Post(
* path="/provider/offers/{id}/cover",
* summary="上傳封面圖片",
* description="上傳課程封面圖片(multipart/form-data",
* operationId="uploadOfferCover",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"cover_image"},
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB")
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadOfferCover()
{
}
/**
* 刪除封面圖片
*
* @OA\Delete(
* path="/provider/offers/{id}/cover",
* summary="刪除封面圖片",
* operationId="deleteOfferCover",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteOfferCover()
{
}
/**
* 上傳相簿圖片
*
* @OA\Post(
* path="/provider/offers/{id}/images",
* summary="上傳相簿圖片",
* description="新增課程相簿圖片(multipart/form-data,可多張)",
* operationId="uploadOfferImages",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"images[]"},
* @OA\Property(
* property="images[]",
* type="array",
* @OA\Items(type="string", format="binary"),
* description="相簿圖片(每張最大 5MB"
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadOfferImages()
{
}
/**
* 刪除相簿圖片
*
* @OA\Delete(
* path="/provider/images/{id}",
* summary="刪除相簿圖片",
* operationId="deleteOfferImage",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="圖片已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteOfferImage()
{
}
// -----------------------------------------------------------------------
// Provider Schedules
// -----------------------------------------------------------------------
/**
* 取得時段列表
*
* @OA\Get(
* path="/provider/schedules",
* summary="取得時段列表",
* description="回傳服務提供者的所有時段,可依 offer_id 篩選",
* operationId="listProviderSchedules",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
* )
* )
* )
*/
public function listProviderSchedules()
{
}
/**
* 建立時段
*
* @OA\Post(
* path="/provider/schedules",
* summary="建立時段",
* operationId="createProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"offer_id","start_date","end_date","available_slots"},
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
* @OA\Property(property="available_slots", type="integer", example=4)
* )
* ),
* @OA\Response(
* response=201,
* description="時段建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* )
* ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createProviderSchedule()
{
}
/**
* 更新時段
*
* @OA\Put(
* path="/provider/schedules/{id}",
* summary="更新時段",
* operationId="updateProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
* @OA\Property(property="available_slots", type="integer", example=6),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* )
* ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateProviderSchedule()
{
}
/**
* 刪除時段
*
* @OA\Delete(
* path="/provider/schedules/{id}",
* summary="刪除時段",
* operationId="deleteProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="時段已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteProviderSchedule()
{
}
// -----------------------------------------------------------------------
// Provider Bookings Management
// -----------------------------------------------------------------------
/**
* 取得收到的預約列表
*
* @OA\Get(
* path="/provider/bookings",
* summary="取得收到的預約列表",
* description="分頁回傳服務提供者收到的所有預約",
* operationId="listProviderBookings",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listProviderBookings()
{
}
/**
* 確認預約
*
* @OA\Put(
* path="/provider/bookings/{id}/confirm",
* summary="確認預約",
* operationId="confirmBooking",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="確認成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已確認"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function confirmBooking()
{
}
/**
* 拒絕預約
*
* @OA\Put(
* path="/provider/bookings/{id}/reject",
* summary="拒絕預約",
* operationId="rejectBooking",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="拒絕成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已拒絕"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function rejectBooking()
{
}
/**
* 標記預約完成
*
* @OA\Put(
* path="/provider/bookings/{id}/complete",
* summary="標記預約完成",
* operationId="completeBookingByProvider",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已完成"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function completeBookingByProvider()
{
}
/**
* 取消預約(Provider
*
* @OA\Put(
* path="/provider/bookings/{id}/cancel",
* summary="取消預約(Provider",
* operationId="cancelBookingByProvider",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取消成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已取消"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function cancelBookingByProvider()
{
}
}
+247
View File
@@ -0,0 +1,247 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="公開課程",
* description="無需認證的公開潛水課程查詢端點"
* )
*
* -----------------------------------------------------------------------
* 共用 Schema 定義
* -----------------------------------------------------------------------
*
* @OA\Schema(
* schema="PaginationMeta",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=72)
* )
*
* @OA\Schema(
* schema="ApiErrorResponse",
* type="object",
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="操作失敗"),
* @OA\Property(property="errors", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="DivingOffer",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="provider_id", type="integer", example=3),
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
* @OA\Property(property="region", type="string", example="墾丁"),
* @OA\Property(property="price", type="number", format="float", example=8500),
* @OA\Property(property="max_participants", type="integer", example=6),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"}),
* @OA\Property(property="cover_image_url", type="string", nullable=true, example="https://example.com/image.jpg"),
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={}),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="Review",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
* @OA\Property(property="is_anonymous", type="boolean", example=false),
* @OA\Property(property="helpful_count", type="integer", example=3),
* @OA\Property(property="has_voted", type="boolean", example=false),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="CourseSchedule",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
* @OA\Property(property="available_slots", type="integer", example=4),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="Booking",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="member_id", type="integer", example=5),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="schedule_id", type="integer", example=2),
* @OA\Property(property="status", type="string", enum={"pending","confirmed","rejected","completed","cancelled"}, example="pending"),
* @OA\Property(property="participants", type="integer", example=2),
* @OA\Property(property="note", type="string", nullable=true, example=""),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*/
class PublicApiDoc
{
/**
* 查詢潛水課程列表
*
* @OA\Get(
* path="/diving-offers",
* summary="查詢潛水課程列表",
* description="支援關鍵字、地區、標籤篩選,回傳分頁結果",
* operationId="listDivingOffers",
* tags={"公開課程"},
* @OA\Parameter(name="q", in="query", description="關鍵字搜尋", @OA\Schema(type="string")),
* @OA\Parameter(name="region", in="query", description="地區篩選", @OA\Schema(type="string", example="墾丁")),
* @OA\Parameter(name="tag", in="query", description="標籤篩選", @OA\Schema(type="string", example="OWD")),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50", @OA\Schema(type="integer", default=15, maximum=50)),
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/DivingOffer")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listDivingOffers()
{
}
/**
* 取得單一潛水課程
*
* @OA\Get(
* path="/diving-offers/{id}",
* summary="取得單一潛水課程",
* description="回傳課程詳情,包含封面圖片與相簿",
* operationId="getDivingOffer",
* tags={"公開課程"},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="課程 ID",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function getDivingOffer()
{
}
/**
* 取得課程評價列表
*
* @OA\Get(
* path="/diving-offers/{id}/reviews",
* summary="取得課程評價列表",
* description="回傳評分摘要、分頁評價列表與分頁 meta",
* operationId="listOfferReviews",
* tags={"公開課程"},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Parameter(name="sort", in="query", description="排序方式(newest / helpful", @OA\Schema(type="string", enum={"newest","helpful"}, default="newest")),
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50", @OA\Schema(type="integer", default=15, maximum=50)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(
* property="summary",
* type="object",
* @OA\Property(property="average_rating", type="number", format="float", example=4.2),
* @OA\Property(property="total_reviews", type="integer", example=24),
* @OA\Property(property="distribution", type="object",
* @OA\Property(property="1", type="integer", example=1),
* @OA\Property(property="2", type="integer", example=2),
* @OA\Property(property="3", type="integer", example=3),
* @OA\Property(property="4", type="integer", example=8),
* @OA\Property(property="5", type="integer", example=10)
* )
* ),
* @OA\Property(
* property="reviews",
* type="array",
* @OA\Items(ref="#/components/schemas/Review")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function listOfferReviews()
{
}
/**
* 取得課程時段列表
*
* @OA\Get(
* path="/diving-offers/{id}/schedules",
* summary="取得課程時段列表",
* description="回傳指定課程的所有可用時段",
* operationId="listOfferSchedules",
* tags={"公開課程"},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/CourseSchedule")
* )
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function listOfferSchedules()
{
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum BookingStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Completed = 'completed';
case Rejected = 'rejected';
case Expired = 'expired';
case MemberCancelled = 'member_cancelled';
case ProviderCancelled = 'provider_cancelled';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ScheduleStatus: string
{
case Open = 'open';
case Full = 'full';
case Cancelled = 'cancelled';
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Enums;
enum VerificationStatus: string
{
case Unsubmitted = 'unsubmitted';
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
public const VALID_TRANSITIONS = [
'unsubmitted' => ['pending'],
'pending' => ['approved', 'rejected'],
'approved' => ['rejected'], // 撤銷驗證,原因必填
'rejected' => ['pending'], // 重新送審
];
public function canTransitionTo(self $newStatus): bool
{
return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageRead implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $bookingId,
public string $readerType,
public int $lastReadMessageId,
) {}
public function broadcastAs(): string
{
return 'MessageRead';
}
public function broadcastOn(): array
{
return [
new PresenceChannel('booking.' . $this->bookingId),
];
}
public function broadcastWith(): array
{
return [
'reader_type' => $this->readerType,
'last_read_message_id' => $this->lastReadMessageId,
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Events;
use App\Models\BookingMessage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public BookingMessage $message) {}
public function broadcastAs(): string
{
return 'MessageSent';
}
public function broadcastOn(): array
{
return [
new PresenceChannel('booking.' . $this->message->booking_id),
];
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'sender_id' => $this->message->sender_id,
'sender_type' => $this->message->sender_type,
'type' => $this->message->type,
'content' => $this->message->content,
'created_at' => $this->message->created_at->toISOString(),
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class NotificationCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public readonly int $userId) {}
public function broadcastOn(): array
{
// 使用 Laravel 內建的 User private channelchannels.php 已有 auth
return [new PrivateChannel("App.Models.User.{$this->userId}")];
}
public function broadcastAs(): string
{
return 'notification.created';
}
// 不需要 payload,前端收到後直接呼叫 fetchUnreadCount()
public function broadcastWith(): array
{
return [];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
class AdminBookingController extends Controller
{
public function index()
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->orderByDesc('created_at')
->get()
->map(fn($b) => [
'id' => $b->id,
'member_name' => $b->member?->name,
'member_email' => $b->member?->email,
'offer_title' => $b->schedule?->divingOffer?->title,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'created_at' => $b->created_at?->toISOString(),
]);
return response()->json(['status' => true, 'data' => $bookings]);
}
public function complete(int $id)
{
$booking = Booking::findOrFail($id);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\Review;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminReviewController extends Controller
{
public function index(Request $request)
{
$perPage = min((int) $request->query('per_page', 20), 100);
$paginator = Review::with(['divingOffer', 'member'])
->orderByDesc('created_at')
->paginate($perPage);
$reviews = $paginator->getCollection()->map(fn($r) => [
'id' => $r->id,
'offer_title' => $r->divingOffer?->title,
'member_email' => $r->member?->email,
'rating' => $r->rating,
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
'is_edited' => $r->is_edited,
'helpful_count'=> $r->helpful_count,
'created_at' => $r->created_at?->toISOString(),
]);
return response()->json([
'status' => true,
'data' => $reviews,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
public function destroy(int $id)
{
$review = Review::findOrFail($id);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
private function recalculateOfferRating(int $offerId): void
{
$stats = Review::where('diving_offer_id', $offerId)
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
->first();
DivingOffer::where('id', $offerId)->update([
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
'reviews' => $stats->total,
]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Cache;
class AdminStatsController extends Controller class AdminStatsController extends Controller
{ {
@@ -14,13 +15,12 @@ class AdminStatsController extends Controller
return response()->json(['status' => false, 'message' => '無權限存取'], 403); return response()->json(['status' => false, 'message' => '無權限存取'], 403);
} }
return response()->json([ $stats = Cache::remember('admin_stats', 300, fn() => [
'status' => true,
'data' => [
'total_members' => User::where('role', 'member')->count(), 'total_members' => User::where('role', 'member')->count(),
'total_providers' => User::where('role', 'provider')->count(), 'total_providers' => User::where('role', 'provider')->count(),
'total_offers' => DivingOffer::count(), 'total_offers' => DivingOffer::count(),
],
]); ]);
return response()->json(['status' => true, 'data' => $stats]);
} }
} }
@@ -131,20 +131,5 @@ class AdminUserController extends Controller
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]); return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
} }
public function toggleProviderVerified(int $id) // toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$profile = $user->providerProfile;
$profile->is_verified = !$profile->is_verified;
$profile->save();
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
}
} }
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\VerificationStatus;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Notifications\ProviderVerificationApprovedNotification;
use App\Notifications\ProviderVerificationRejectedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AdminVerificationController extends Controller
{
public function index(Request $request)
{
$status = $request->query('status', VerificationStatus::Pending->value);
$query = User::where('role', 'provider')
->with(['providerProfile', 'providerCertifications'])
->whereHas('providerProfile');
if ($status !== 'all') {
$query->whereHas('providerProfile', fn($q) => $q->where('verification_status', $status));
}
$providers = $query->orderByDesc('updated_at')->get()->map(fn($u) => [
'user_id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'business_name' => $u->providerProfile->business_name,
'verification_status' => $u->providerProfile->verification_status->value,
'rejection_reason' => $u->providerProfile->rejection_reason,
'certifications' => $u->providerCertifications->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
]);
return response()->json(['status' => true, 'data' => $providers]);
}
public function approve(Request $request, int $userId)
{
$profile = $this->findProviderProfile($userId);
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Approved)) {
return response()->json(['status' => false, 'message' => '當前狀態無法通過審核'], 422);
}
$profile->update(['verification_status' => VerificationStatus::Approved, 'rejection_reason' => null]);
// 驗證狀態影響公開課程可見性,需立即讓快取失效
Cache::tags(['diving_offers'])->flush();
$this->notify($profile->user, new ProviderVerificationApprovedNotification());
return response()->json(['status' => true, 'message' => '教練已通過審核']);
}
public function reject(Request $request, int $userId)
{
$data = $request->validate(['reason' => 'required|string|max:500']);
$profile = $this->findProviderProfile($userId);
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Rejected)) {
return response()->json(['status' => false, 'message' => '當前狀態無法駁回'], 422);
}
$profile->update([
'verification_status' => VerificationStatus::Rejected,
'rejection_reason' => $data['reason'],
]);
Cache::tags(['diving_offers'])->flush();
$this->notify($profile->user, new ProviderVerificationRejectedNotification($data['reason']));
return response()->json(['status' => true, 'message' => '已駁回,教練將收到原因通知']);
}
private function findProviderProfile(int $userId)
{
return User::where('role', 'provider')->findOrFail($userId)->providerProfile()->firstOrFail();
}
private function notify(User $provider, object $notification): void
{
try {
$provider->notify($notification);
} catch (\Throwable $e) {
Log::error('ProviderVerification notification failed: ' . $e->getMessage());
}
}
}
+140 -74
View File
@@ -7,15 +7,19 @@ use App\Models\User;
use App\Models\AdminProfile; use App\Models\AdminProfile;
use App\Models\ProviderProfile; use App\Models\ProviderProfile;
use App\Models\MemberProfile; use App\Models\MemberProfile;
use App\Traits\NormalizesEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
class AuthController extends Controller class AuthController extends Controller
{ {
// 科定規範角色 use NormalizesEmail;
private const ROLE_MEMBER = 'member'; private const ROLE_MEMBER = 'member';
private const ROLE_PROVIDER = 'provider'; private const ROLE_PROVIDER = 'provider';
private const ROLE_ADMIN = 'admin'; private const ROLE_ADMIN = 'admin';
@@ -131,6 +135,50 @@ class AuthController extends Controller
]); ]);
} }
private function isAccountLocked(string $role, string $email): bool
{
$count = Cache::get("login_failures:{$role}:{$email}", 0);
return $count >= config('auth_lockout.max_attempts');
}
private function recordLoginFailure(string $role, string $email): int
{
$ttl = config('auth_lockout.decay_minutes') * 60;
$countKey = "login_failures:{$role}:{$email}";
$expiresKey = "login_expires_at:{$role}:{$email}";
if (!Cache::has($countKey)) {
Cache::put($countKey, 1, $ttl);
Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl);
return 1;
}
return Cache::increment($countKey);
}
private function clearLoginFailures(string $role, string $email): void
{
Cache::forget("login_failures:{$role}:{$email}");
Cache::forget("login_expires_at:{$role}:{$email}");
}
private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse
{
$decayMinutes = config('auth_lockout.decay_minutes');
$lockedUntil = Cache::get("login_expires_at:{$role}:{$email}");
if (!$lockedUntil) {
Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}");
$lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String();
}
return response()->json([
'status' => false,
'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試",
'locked_until' => $lockedUntil,
], 423);
}
/** /**
* 標準化的登出方法 * 標準化的登出方法
*/ */
@@ -268,7 +316,6 @@ class AuthController extends Controller
*/ */
public function loginMember(Request $request) public function loginMember(Request $request)
{ {
// 驗證請求數據
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|string|email', 'email' => 'required|string|email',
'password' => 'required|string', 'password' => 'required|string',
@@ -282,20 +329,30 @@ class AuthController extends Controller
], 422); ], 422);
} }
// 檢查用戶是否存在 $email = $this->normalizeEmail($request->email);
$user = User::where('email', $request->email)
->where('role', 'member') // 只驗證會員
->first();
// 檢查密碼 // 1st: 帳號鎖定檢查
if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) {
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
}
// 2nd: 帳號存在確認
$user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first();
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
if (!$user || !Hash::check($request->password, $user->password)) { if (!$user || !Hash::check($request->password, $user->password)) {
if ($user) {
$count = $this->recordLoginFailure(self::ROLE_MEMBER, $email);
if ($count >= config('auth_lockout.max_attempts')) {
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
}
}
return response()->json([ return response()->json([
'status' => false, 'status' => false,
'message' => '電子郵件或密碼錯誤' 'message' => '電子郵件或密碼錯誤'
], 401); ], 401);
} }
// 檢查用戶是否啟用
if (!$user->is_active) { if (!$user->is_active) {
return response()->json([ return response()->json([
'status' => false, 'status' => false,
@@ -303,10 +360,8 @@ class AuthController extends Controller
], 403); ], 403);
} }
// 創建 API token $this->clearLoginFailures(self::ROLE_MEMBER, $email);
$token = $user->createToken('auth_token')->plainTextToken; $token = $user->createToken('auth_token')->plainTextToken;
// 加載會員資料
$user->load('memberProfile'); $user->load('memberProfile');
return response()->json([ return response()->json([
@@ -531,7 +586,6 @@ class AuthController extends Controller
*/ */
public function loginProvider(Request $request) public function loginProvider(Request $request)
{ {
// 驗證請求數據
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|string|email', 'email' => 'required|string|email',
'password' => 'required|string', 'password' => 'required|string',
@@ -545,20 +599,30 @@ class AuthController extends Controller
], 422); ], 422);
} }
// 檢查用戶是否存在 $email = $this->normalizeEmail($request->email);
$user = User::where('email', $request->email)
->where('role', 'provider') // 只驗證服務提供者
->first();
// 檢查密碼 // 1st: 帳號鎖定檢查
if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) {
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
}
// 2nd: 帳號存在確認
$user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first();
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
if (!$user || !Hash::check($request->password, $user->password)) { if (!$user || !Hash::check($request->password, $user->password)) {
if ($user) {
$count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email);
if ($count >= config('auth_lockout.max_attempts')) {
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
}
}
return response()->json([ return response()->json([
'status' => false, 'status' => false,
'message' => '電子郵件或密碼錯誤' 'message' => '電子郵件或密碼錯誤'
], 401); ], 401);
} }
// 檢查用戶是否啟用
if (!$user->is_active) { if (!$user->is_active) {
return response()->json([ return response()->json([
'status' => false, 'status' => false,
@@ -566,10 +630,8 @@ class AuthController extends Controller
], 403); ], 403);
} }
// 創建 API token $this->clearLoginFailures(self::ROLE_PROVIDER, $email);
$token = $user->createToken('auth_token')->plainTextToken; $token = $user->createToken('auth_token')->plainTextToken;
// 加載服務提供者資料
$user->load('providerProfile'); $user->load('providerProfile');
return response()->json([ return response()->json([
@@ -784,59 +846,6 @@ class AuthController extends Controller
]); ]);
} }
/**
* 管理員註冊
*/
public function registerAdmin(Request $request)
{
// 驗證請求數據
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:100',
'department' => 'nullable|string|max:100',
]);
if ($validator->fails()) {
return response()->json([
'status' => false,
'message' => '驗證失敗',
'errors' => $validator->errors()
], 422);
}
// 創建用戶
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'phone' => $request->phone,
'role' => 'admin', // 強制為管理員角色
]);
// 創建管理員資料
AdminProfile::create([
'user_id' => $user->id,
'position' => $request->position,
'department' => $request->department,
]);
// 創建 API token
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'message' => '管理員註冊成功',
'data' => [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]
], 201);
}
/** /**
* 管理員登入 * 管理員登入
*/ */
@@ -1051,6 +1060,63 @@ class AuthController extends Controller
]); ]);
} }
/**
* 會員 Token Refresh
*/
public function refreshMember(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if ($user->role !== self::ROLE_MEMBER) {
return $this->getUnauthorizedResponse();
}
$request->user()->currentAccessToken()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'data' => ['token' => $token, 'token_type' => 'Bearer'],
]);
}
/**
* 服務提供者 Token Refresh
*/
public function refreshProvider(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if ($user->role !== self::ROLE_PROVIDER) {
return $this->getUnauthorizedResponse();
}
$request->user()->currentAccessToken()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'data' => ['token' => $token, 'token_type' => 'Bearer'],
]);
}
/**
* 管理員 Token Refresh
*/
public function refreshAdmin(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if ($user->role !== self::ROLE_ADMIN) {
return $this->getUnauthorizedResponse();
}
$request->user()->currentAccessToken()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'data' => ['token' => $token, 'token_type' => 'Bearer'],
]);
}
/** /**
* 查詢會員資料 * 查詢會員資料
* 只有管理員可以使用這個方法 * 只有管理員可以使用這個方法
@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers\API;
use App\Events\MessageRead;
use App\Events\MessageSent;
use App\Events\NotificationCreated;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\BookingMessage;
use App\Models\User;
use App\Notifications\NewBookingMessageNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
class BookingMessageController extends Controller
{
private function authorizeParticipant(Request $request, Booking $booking): bool
{
$user = $request->user();
$booking->loadMissing('schedule');
if ($user->role === 'member') {
return $booking->member_id === $user->id;
}
if ($user->role === 'provider') {
return $booking->schedule->provider_id === $user->id;
}
return false;
}
public function unreadCounts(Request $request): JsonResponse
{
$user = $request->user();
$senderType = $user->role === 'member' ? 'member' : 'provider';
$otherType = $senderType === 'member' ? 'provider' : 'member';
if ($senderType === 'member') {
$bookingIds = Booking::where('member_id', $user->id)
->whereIn('status', ['confirmed', 'completed'])
->pluck('id');
} else {
$bookingIds = Booking::whereHas('schedule', fn($q) => $q->where('provider_id', $user->id))
->whereIn('status', ['confirmed', 'completed'])
->pluck('id');
}
// 一次 query 取得所有 booking 的未讀數(只計對方發的、且 read_at 為 NULL
$counts = BookingMessage::whereIn('booking_id', $bookingIds)
->where('sender_type', $otherType)
->whereNull('read_at')
->selectRaw('booking_id, COUNT(*) as count')
->groupBy('booking_id')
->pluck('count', 'booking_id');
return response()->json(['status' => true, 'data' => $counts]);
}
public function index(Request $request, Booking $booking): JsonResponse
{
if (!$this->authorizeParticipant($request, $booking)) {
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
}
$status = $booking->status->value;
if (!in_array($status, ['confirmed', 'completed'])) {
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
}
$messages = $booking->messages()->orderBy('created_at')->get();
return response()->json(['status' => true, 'data' => $messages]);
}
public function store(Request $request, Booking $booking): JsonResponse
{
if (!$this->authorizeParticipant($request, $booking)) {
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
}
if ($booking->status->value !== 'confirmed') {
return response()->json(['status' => false, 'message' => '訊息功能僅在預約確認期間開放'], 403);
}
$request->validate(['type' => 'required|in:text,image']);
$user = $request->user();
$senderType = $user->role === 'member' ? 'member' : 'provider';
if ($request->input('type') === 'text') {
$request->validate(['content' => 'required|string|max:5000']);
$message = BookingMessage::create([
'booking_id' => $booking->id,
'sender_id' => $user->id,
'sender_type' => $senderType,
'type' => 'text',
'content' => $request->input('content'),
]);
} else {
$request->validate([
'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
]);
$manager = new ImageManager(new Driver());
$image = $manager->read($request->file('file'));
if ($image->width() > 2048 || $image->height() > 2048) {
$image->scaleDown(width: 2048, height: 2048);
}
$uuid = Str::uuid();
$filename = "booking-images/{$uuid}.jpg";
$encoded = $image->toJpeg(quality: 85);
Storage::disk('public')->put($filename, $encoded);
$url = Storage::disk('public')->url($filename);
$message = BookingMessage::create([
'booking_id' => $booking->id,
'sender_id' => $user->id,
'sender_type' => $senderType,
'type' => 'image',
'content' => $url,
]);
}
broadcast(new MessageSent($message));
// 通知另一方(寫入 DB notification,讓 Bell 圖示更新)
$receiverId = $senderType === 'member'
? $booking->schedule->provider_id
: $booking->member_id;
$receiver = User::find($receiverId);
if ($receiver) {
// 同步寫進 DB(非 queue),確保下一行 broadcast 時 unread count 已是最新
$receiver->notify(new NewBookingMessageNotification($message, $booking, $user));
// 通知前端:立刻更新 bell badge,不等 polling
broadcast(new NotificationCreated($receiver->id));
}
return response()->json(['status' => true, 'data' => $message], 201);
}
public function markRead(Request $request, Booking $booking): JsonResponse
{
if (!$this->authorizeParticipant($request, $booking)) {
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
}
$status = $booking->status->value;
if (!in_array($status, ['confirmed', 'completed'])) {
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
}
$request->validate(['last_read_message_id' => 'required|integer']);
$user = $request->user();
$senderType = $user->role === 'member' ? 'member' : 'provider';
$otherType = $senderType === 'member' ? 'provider' : 'member';
$updated = BookingMessage::where('booking_id', $booking->id)
->where('sender_type', $otherType)
->where('id', '<=', $request->input('last_read_message_id'))
->whereNull('read_at')
->update(['read_at' => now()]);
if ($updated > 0 && $status === 'confirmed') {
broadcast(new MessageRead(
$booking->id,
$senderType,
$request->input('last_read_message_id'),
));
}
return response()->json(['status' => true]);
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use App\Traits\CompressesImages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CourseImageController extends Controller
{
use CompressesImages;
private function validateImage(Request $request): void
{
$request->validate([
// 10MB:伺服器端會壓縮(compressToJpeg),放寬以容納手機原圖
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
]);
}
private function authorizeOffer(Request $request, DivingOffer $offer): void
{
if ($offer->provider_id !== $request->user()->id) {
abort(403, '無權操作此課程');
}
}
public function uploadCover(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
$this->validateImage($request);
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
}
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/cover");
$offer->update(['cover_image' => $path]);
return response()->json([
'status' => true,
'message' => '封面已上傳',
'cover_image_url' => $offer->cover_image_url,
]);
}
public function deleteCover(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
$offer->update(['cover_image' => null]);
}
return response()->json(['status' => true, 'message' => '封面已刪除']);
}
public function uploadImage(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
$this->validateImage($request);
if ($offer->courseImages()->count() >= 3) {
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
}
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/gallery");
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
$image = CourseImage::create([
'diving_offer_id' => $offerId,
'image_path' => $path,
'sort_order' => $sortOrder,
]);
return response()->json([
'status' => true,
'message' => '圖片已上傳',
'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order],
], 201);
}
public function deleteImage(Request $request, int $imageId)
{
$image = CourseImage::with('divingOffer')->findOrFail($imageId);
if ($image->divingOffer->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403);
}
Storage::disk('public')->delete($image->image_path);
$image->delete();
return response()->json(['status' => true, 'message' => '圖片已刪除']);
}
}
@@ -5,14 +5,17 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DivingOfferController extends Controller class DivingOfferController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$perPage = min((int) $request->query('per_page', 12), 50); $perPage = min((int) $request->query('per_page', 12), 50);
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
$query = DivingOffer::query(); $result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
$query = DivingOffer::query()->visibleToPublic();
if ($q = $request->query('q')) { if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) { $query->where(function ($sub) use ($q) {
@@ -32,32 +35,49 @@ class DivingOfferController extends Controller
$paginated = $query->paginate($perPage); $paginated = $query->paginate($perPage);
return response()->json([ return [
'status' => true, 'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
'data' => $paginated->items(),
'meta' => [ 'meta' => [
'total' => $paginated->total(), 'total' => $paginated->total(),
'per_page' => $paginated->perPage(), 'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(), 'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(), 'last_page' => $paginated->lastPage(),
], ],
];
});
return response()->json([
'status' => true,
'data' => $result['items'],
'meta' => $result['meta'],
]); ]);
} }
public function show(int $id) public function show(int $id)
{ {
$offer = DivingOffer::find($id); $offer = DivingOffer::with('courseImages')->visibleToPublic()->find($id);
if (!$offer) { if (!$offer) {
return response()->json([ return response()->json(['status' => false, 'message' => '課程不存在'], 404);
'status' => false,
'message' => '課程不存在',
], 404);
} }
return response()->json([ return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
'status' => true, }
'data' => $offer,
private function formatOffer(DivingOffer $offer, bool $withImages): array
{
$data = array_merge($offer->toArray(), [
'cover_image_url' => $offer->cover_image_url,
]); ]);
if ($withImages) {
$data['images'] = $offer->courseImages->map(fn($img) => [
'id' => $img->id,
'url' => $img->url,
'sort_order' => $img->sort_order,
])->values();
}
return $data;
} }
} }
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
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
{
public function index(Request $request)
{
$bookings = Booking::with(['schedule.divingOffer'])
->where('member_id', $request->user()->id)
->orderByDesc('created_at')
->get()
->map(fn($b) => $this->formatBooking($b));
return response()->json(['status' => true, 'data' => $bookings]);
}
public function show(Request $request, int $id)
{
$booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
if ($booking->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
}
return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
}
public function store(Request $request)
{
$data = $request->validate([
'schedule_id' => 'required|integer|exists:course_schedules,id',
'participants' => 'required|integer|min:1',
'notes' => 'nullable|string|max:500',
]);
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
// Layer 1:快速失敗
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
$offer = $schedule->divingOffer;
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
// is_verified 為 accessor= verification_status === approved
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
}
if ($schedule->status !== ScheduleStatus::Open) {
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
}
if ($data['participants'] > $schedule->remainingSpots()) {
return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
}
$memberId = $request->user()->id;
try {
$booking = DB::transaction(function () use ($data, $schedule, $memberId) {
// Layer 2lockForUpdate 後二次驗證
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
if ($data['participants'] > $schedule->remainingSpots()) {
throw new \RuntimeException('名額不足,請重新選擇');
}
// 重複預約檢查
$duplicate = Booking::where('member_id', $memberId)
->where('schedule_id', $schedule->id)
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
->exists();
if ($duplicate) {
throw new \RuntimeException('您已預約此時段');
}
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $memberId,
'participants' => $data['participants'],
'total_price' => $schedule->divingOffer->price * $data['participants'],
'status' => BookingStatus::Pending,
'notes' => $data['notes'] ?? null,
]);
});
} catch (\RuntimeException $e) {
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' => '預約已送出,等待教練確認',
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
], 201);
}
public function destroy(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
if ($booking->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
}
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
if (!in_array($booking->status, $canCancelFrom)) {
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
}
// 24h 截止驗證
$schedule = $booking->schedule;
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
if (now()->diffInHours($courseStart, false) < 24) {
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
}
DB::transaction(function () use ($booking, $schedule) {
$wasConfirmed = $booking->status === BookingStatus::Confirmed;
$booking->update(['status' => BookingStatus::MemberCancelled]);
if ($wasConfirmed) {
$schedule = $booking->schedule()->lockForUpdate()->first();
$schedule->decrement('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants < $schedule->max_participants
&& $schedule->status === ScheduleStatus::Full) {
$schedule->update(['status' => ScheduleStatus::Open]);
}
}
});
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' => '預約已取消']);
}
private function formatBooking(Booking $b): array
{
$offer = $b->schedule?->divingOffer;
return [
'id' => $b->id,
'offer_id' => $offer?->id,
'offer_title' => $offer?->title,
'offer_location' => $offer?->location,
'offer_region' => $offer?->region,
'offer_price' => $offer?->price,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'notes' => $b->notes,
'created_at' => $b->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request)
{
try {
$user = $request->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);
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\API;
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
{
public function index(Request $request)
{
$provider = $request->user();
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id))
->orderByDesc('created_at')
->get()
->map(fn($b) => $this->formatBooking($b));
return response()->json(['status' => true, 'data' => $bookings]);
}
public function confirm(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Confirmed)) {
return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422);
}
try {
DB::transaction(function () use ($booking) {
$schedule = $booking->schedule()->lockForUpdate()->first();
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($booking->participants > $remaining) {
throw new \RuntimeException('名額不足,無法確認此預約');
}
$booking->update(['status' => BookingStatus::Confirmed]);
$schedule->increment('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants >= $schedule->max_participants) {
$schedule->update(['status' => ScheduleStatus::Full]);
}
});
} catch (\RuntimeException $e) {
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']))]);
}
public function reject(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Rejected)) {
return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422);
}
$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' => '預約已拒絕']);
}
public function cancel(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422);
}
DB::transaction(function () use ($booking) {
$schedule = $booking->schedule()->lockForUpdate()->first();
$booking->update(['status' => BookingStatus::ProviderCancelled]);
$schedule->decrement('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants < $schedule->max_participants
&& $schedule->status === ScheduleStatus::Full) {
$schedule->update(['status' => ScheduleStatus::Open]);
}
});
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' => '預約已取消']);
}
public function complete(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$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' => '預約已標記為完成']);
}
private function authorizeProvider(Request $request, Booking $booking): void
{
if ($booking->schedule->provider_id !== $request->user()->id) {
abort(403, '無權操作此預約');
}
}
private function formatBooking(Booking $b): array
{
return [
'id' => $b->id,
'member_name' => $b->member?->name,
'member_email' => $b->member?->email,
'offer_title' => $b->schedule?->divingOffer?->title,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'notes' => $b->notes,
'created_at' => $b->created_at?->toISOString(),
];
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProviderOfferController extends Controller class ProviderOfferController extends Controller
{ {
@@ -60,6 +61,8 @@ class ProviderOfferController extends Controller
$offer = DivingOffer::create($validated); $offer = DivingOffer::create($validated);
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer], 201); return response()->json(['status' => true, 'data' => $offer], 201);
} }
@@ -89,6 +92,8 @@ class ProviderOfferController extends Controller
$offer->fill($validated)->save(); $offer->fill($validated)->save();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer]); return response()->json(['status' => true, 'data' => $offer]);
} }
@@ -106,6 +111,8 @@ class ProviderOfferController extends Controller
$offer->delete(); $offer->delete();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'message' => '課程已刪除']); return response()->json(['status' => true, 'message' => '課程已刪除']);
} }
} }
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\VerificationStatus;
use App\Http\Controllers\Controller;
use App\Models\ProviderCertification;
use App\Traits\CompressesImages;
use Illuminate\Http\Request;
class ProviderVerificationController extends Controller
{
use CompressesImages;
private const MAX_CERTIFICATIONS = 3;
public function show(Request $request)
{
$profile = $request->user()->providerProfile;
return response()->json([
'status' => true,
'data' => [
'verification_status' => $profile->verification_status->value,
'rejection_reason' => $profile->rejection_reason,
'certifications' => $request->user()->providerCertifications
->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
],
]);
}
public function uploadCertification(Request $request)
{
$request->validate([
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
]);
if ($err = $this->ensureCertificationsEditable($request)) {
return $err;
}
$user = $request->user();
if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) {
return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422);
}
$path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications");
$certification = ProviderCertification::create([
'user_id' => $user->id,
'image_path' => $path,
]);
return response()->json([
'status' => true,
'message' => '證照已上傳',
'data' => ['id' => $certification->id, 'url' => $certification->url],
], 201);
}
public function deleteCertification(Request $request, int $id)
{
$certification = ProviderCertification::findOrFail($id);
if ($certification->user_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403);
}
if ($err = $this->ensureCertificationsEditable($request)) {
return $err;
}
$certification->delete();
return response()->json(['status' => true, 'message' => '證照已刪除']);
}
public function submit(Request $request)
{
$user = $request->user();
$profile = $user->providerProfile;
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) {
return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422);
}
if ($user->providerCertifications()->count() < 1) {
return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422);
}
$profile->update([
'verification_status' => VerificationStatus::Pending,
'rejection_reason' => null,
]);
return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']);
}
/**
* 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動
*/
private function ensureCertificationsEditable(Request $request)
{
$status = $request->user()->providerProfile->verification_status;
if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) {
return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422);
}
return null;
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
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\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ReviewController extends Controller
{
// ── 公開列表 ──────────────────────────────────────────────
public function publicList(Request $request, int $offerId)
{
$offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
$user = $request->user();
$perPage = min((int) $request->query('per_page', 20), 50);
$sort = $request->query('sort', 'helpful');
$query = Review::with('votes')->where('diving_offer_id', $offer->id);
match ($sort) {
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
'newest' => $query->orderByDesc('created_at'),
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
};
$paginator = $query->paginate($perPage);
$reviews = $paginator->getCollection();
$memberId = $user?->id;
$distribution = Cache::remember("offer_review_distribution_{$offerId}", 600, function () use ($offerId) {
$distRaw = Review::where('diving_offer_id', $offerId)
->selectRaw('rating, COUNT(*) as cnt')
->groupBy('rating')
->pluck('cnt', 'rating');
return collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
});
$total = $paginator->total();
$average = $total > 0 ? round(
Review::where('diving_offer_id', $offerId)->avg('rating'), 1
) : 0;
$formatted = $reviews->map(function ($r) use ($user, $memberId) {
$item = [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
'has_voted' => $memberId
? $r->votes->contains('member_id', $memberId)
: false,
];
if ($user) {
$item['is_mine'] = $r->member_id === $memberId;
}
return $item;
});
return response()->json([
'status' => true,
'data' => [
'summary' => [
'average' => $average,
'total' => $total,
'distribution' => $distribution,
],
'reviews' => $formatted,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
],
]);
}
// ── Member CRUD ───────────────────────────────────────────
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|min:1',
]);
$memberId = $request->user()->id;
$offerId = $data['diving_offer_id'];
// 資格驗證:有 completed booking
$eligible = Booking::where('member_id', $memberId)
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
->where('status', BookingStatus::Completed->value)
->exists();
if (!$eligible) {
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
}
// 重複評價檢查
if (Review::where('member_id', $memberId)->where('diving_offer_id', $offerId)->exists()) {
return response()->json(['status' => false, 'message' => '已評價,如需修改請使用編輯功能'], 422);
}
$review = DB::transaction(function () use ($data, $memberId, $offerId) {
$review = Review::create([
'diving_offer_id' => $offerId,
'member_id' => $memberId,
'rating' => $data['rating'],
'comment' => $data['comment'],
]);
$this->recalculateOfferRating($offerId);
return $review;
});
Cache::forget("offer_review_distribution_{$offerId}");
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);
}
public function update(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權修改此評價'], 403);
}
$data = $request->validate([
'rating' => 'sometimes|integer|min:1|max:5',
'comment' => 'sometimes|string|min:1',
]);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $data) {
ReviewEdit::updateOrCreate(
['review_id' => $review->id],
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
);
$review->update(array_merge($data, ['is_edited' => true]));
$this->recalculateOfferRating($review->diving_offer_id);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
}
public function destroy(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此評價'], 403);
}
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
// ── 有幫助投票 ────────────────────────────────────────────
public function toggleHelpful(Request $request, int $id)
{
if (!$request->user()->isMember()) {
return response()->json(['status' => false, 'message' => '只有會員可以投票'], 403);
}
$review = Review::findOrFail($id);
$memberId = $request->user()->id;
if ($review->member_id === $memberId) {
return response()->json(['status' => false, 'message' => '不可對自己的評價投票'], 422);
}
DB::transaction(function () use ($review, $memberId) {
$vote = ReviewVote::where('review_id', $review->id)
->where('member_id', $memberId)
->first();
if ($vote) {
$vote->delete();
DB::table('reviews')
->where('id', $review->id)
->where('helpful_count', '>', 0)
->decrement('helpful_count');
} else {
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId, 'created_at' => now()]);
$review->increment('helpful_count');
}
});
$review->refresh();
$hasVoted = ReviewVote::where('review_id', $review->id)->where('member_id', $memberId)->exists();
return response()->json(['status' => true, 'data' => ['helpful_count' => $review->helpful_count, 'has_voted' => $hasVoted]]);
}
// ── 私有方法 ──────────────────────────────────────────────
private function recalculateOfferRating(int $offerId): void
{
$stats = Review::where('diving_offer_id', $offerId)
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
->first();
DivingOffer::where('id', $offerId)->update([
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
'reviews' => $stats->total,
]);
}
private function formatReview(Review $r): array
{
return [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ScheduleController extends Controller
{
public function index(Request $request)
{
$provider = $request->user();
$schedules = CourseSchedule::with('divingOffer')
->where('provider_id', $provider->id)
->orderBy('scheduled_date')
->orderBy('start_time')
->get()
->map(fn($s) => $this->formatSchedule($s));
return response()->json(['status' => true, 'data' => $schedules]);
}
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'scheduled_date' => 'required|date|after_or_equal:today',
'start_time' => 'required|date_format:H:i',
'max_participants' => 'required|integer|min:1',
]);
$offer = DivingOffer::findOrFail($data['diving_offer_id']);
if ($offer->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此課程'], 403);
}
$schedule = CourseSchedule::create([
'diving_offer_id' => $data['diving_offer_id'],
'provider_id' => $request->user()->id,
'scheduled_date' => $data['scheduled_date'],
'start_time' => $data['start_time'],
'max_participants' => $data['max_participants'],
'current_participants'=> 0,
'status' => ScheduleStatus::Open,
]);
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201);
}
public function update(Request $request, int $id)
{
$schedule = CourseSchedule::findOrFail($id);
if ($schedule->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
}
$data = $request->validate([
'start_time' => 'sometimes|date_format:H:i',
'max_participants' => 'sometimes|integer|min:1',
]);
if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) {
return response()->json([
'status' => false,
'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . '',
], 422);
}
$schedule->update($data);
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]);
}
public function destroy(Request $request, int $id)
{
$schedule = CourseSchedule::findOrFail($id);
if ($schedule->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
}
DB::transaction(function () use ($schedule) {
$schedule->update(['status' => ScheduleStatus::Cancelled]);
$schedule->bookings()
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
->update(['status' => BookingStatus::ProviderCancelled->value]);
});
return response()->json(['status' => true, 'message' => '時段已取消']);
}
public function publicList(int $offerId)
{
$offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
->where('status', ScheduleStatus::Open->value)
->whereDate('scheduled_date', '>=', now()->toDateString())
->orderBy('scheduled_date')
->orderBy('start_time')
->get()
->map(fn($s) => [
'id' => $s->id,
'scheduled_date' => $s->scheduled_date->toDateString(),
'start_time' => $s->start_time,
'max_participants' => $s->max_participants,
'remaining_spots' => $s->remainingSpots(),
'status' => $s->status->value,
]);
return response()->json(['status' => true, 'data' => $schedules]);
}
private function formatSchedule(CourseSchedule $s): array
{
return [
'id' => $s->id,
'diving_offer_id' => $s->diving_offer_id,
'offer_title' => $s->divingOffer?->title,
'scheduled_date' => $s->scheduled_date?->toDateString(),
'start_time' => $s->start_time,
'max_participants' => $s->max_participants,
'current_participants' => $s->current_participants,
'remaining_spots' => $s->remainingSpots(),
'status' => $s->status->value,
];
}
}
@@ -18,11 +18,19 @@ class SocialAuthController extends Controller
*/ */
public function redirectToGoogle() public function redirectToGoogle()
{ {
return Socialite::driver('google') $state = bin2hex(random_bytes(32));
session()->put('oauth_state', $state);
$redirect = Socialite::driver('google')
->scopes(['openid', 'profile', 'email']) ->scopes(['openid', 'profile', 'email'])
->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token ->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state])
->stateless()
->redirect(); ->redirect();
// Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。
// 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。
session()->put('state', $state);
return $redirect;
} }
/** /**
@@ -30,9 +38,16 @@ class SocialAuthController extends Controller
*/ */
public function handleGoogleCallback(Request $request) public function handleGoogleCallback(Request $request)
{ {
$requestState = $request->input('state');
$sessionState = session()->pull('oauth_state');
if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) {
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
}
try { try {
// 獲取 Google 用戶資訊 // 獲取 Google 用戶資訊Socialite 內建 state 驗證此時也會通過)
$googleUser = Socialite::driver('google')->stateless()->user(); $googleUser = Socialite::driver('google')->user();
// 查找相關的社交帳號 // 查找相關的社交帳號
$socialAccount = SocialAccount::where('provider', 'google') $socialAccount = SocialAccount::where('provider', 'google')
@@ -106,9 +121,9 @@ class SocialAuthController extends Controller
// 生成 Sanctum token // 生成 Sanctum token
$token = $user->createToken('google-auth')->plainTextToken; $token = $user->createToken('google-auth')->plainTextToken;
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token); return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token);
} catch (\Exception $e) { } catch (\Exception $e) {
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed'); return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
} }
} }
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdmin()) {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return $next($request);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\BookingStatus;
use Illuminate\Database\Eloquent\Model;
class Booking extends Model
{
protected $fillable = [
'schedule_id',
'member_id',
'participants',
'total_price',
'status',
'notes',
];
protected $casts = [
'participants' => 'integer',
'total_price' => 'integer',
'status' => BookingStatus::class,
];
const VALID_TRANSITIONS = [
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
'completed' => [],
'rejected' => [],
'expired' => [],
'member_cancelled' => [],
'provider_cancelled' => [],
];
public function canTransitionTo(BookingStatus $newStatus): bool
{
$current = $this->status->value;
$allowed = self::VALID_TRANSITIONS[$current] ?? [];
return in_array($newStatus->value, $allowed);
}
public function schedule()
{
return $this->belongsTo(CourseSchedule::class, 'schedule_id');
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
public function messages()
{
return $this->hasMany(BookingMessage::class);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BookingMessage extends Model
{
protected $fillable = [
'booking_id',
'sender_id',
'sender_type',
'type',
'content',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function booking()
{
return $this->belongsTo(Booking::class);
}
public function sender()
{
return $this->belongsTo(User::class, 'sender_id');
}
}
-32
View File
@@ -1,32 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
// 該模型已棄用,請使用ProviderProfile模型
class CoachProfile extends Model
{
/**
* 可以批量分配的屬性
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'bio',
'expertise',
'certification',
'experience',
'rating',
'availability'
];
/**
* 與用戶的關聯
*/
public function user()
{
return $this->belongsTo(User::class);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CourseImage extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
protected $fillable = [
'diving_offer_id',
'image_path',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
'created_at' => 'datetime',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function getUrlAttribute(): string
{
return Storage::disk('public')->url($this->image_path);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Enums\ScheduleStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class CourseSchedule extends Model
{
protected $fillable = [
'diving_offer_id',
'provider_id',
'scheduled_date',
'start_time',
'max_participants',
'current_participants',
'status',
];
protected $casts = [
'scheduled_date' => 'date',
'max_participants' => 'integer',
'current_participants' => 'integer',
'status' => ScheduleStatus::class,
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function provider()
{
return $this->belongsTo(User::class, 'provider_id');
}
public function bookings()
{
return $this->hasMany(Booking::class, 'schedule_id');
}
public function remainingSpots(): int
{
return max(0, $this->max_participants - $this->current_participants);
}
protected function startTime(): Attribute
{
return Attribute::make(
get: fn($value) => $value ? substr($value, 0, 5) : $value,
);
}
}
+50
View File
@@ -2,7 +2,10 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class DivingOffer extends Model class DivingOffer extends Model
{ {
@@ -22,6 +25,7 @@ class DivingOffer extends Model
'description', 'description',
'tag', 'tag',
'region', 'region',
'cover_image',
]; ];
protected $casts = [ protected $casts = [
@@ -30,4 +34,50 @@ class DivingOffer extends Model
'price' => 'integer', 'price' => 'integer',
'reviews'=> 'integer', 'reviews'=> 'integer',
]; ];
protected static function booted(): void
{
static::deleting(function ($offer) {
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
});
}
public function getCoverImageUrlAttribute(): ?string
{
return $this->cover_image
? Storage::disk('public')->url($this->cover_image)
: null;
}
public function provider(): BelongsTo
{
return $this->belongsTo(User::class, 'provider_id');
}
/**
* 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。
* provider_id null 的課程(平台自有資料)不受此限制。
*/
public function scopeVisibleToPublic(Builder $query): Builder
{
return $query->where(function (Builder $visible) {
$visible->whereNull('provider_id')
->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value));
});
}
public function schedules()
{
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
}
public function courseImages()
{
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
}
public function reviews()
{
return $this->hasMany(Review::class);
}
} }
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
//
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ProviderCertification extends Model
{
protected $fillable = [
'user_id',
'image_path',
];
protected static function booted(): void
{
static::deleting(function ($certification) {
Storage::disk('public')->delete($certification->image_path);
});
}
public function getUrlAttribute(): string
{
return Storage::disk('public')->url($this->image_path);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+21 -2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\VerificationStatus;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@@ -23,7 +24,9 @@ use Illuminate\Database\Eloquent\Model;
* @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"), * @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"),
* @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"), * @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"),
* @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"), * @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"),
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證"), * @OA\Property(property="verification_status", type="string", example="approved", description="審核狀態:unsubmitted / pending / approved / rejected"),
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null, description="駁回原因(rejected 時有值)"),
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證(相容欄位,= verification_status 為 approved"),
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"), * @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
* @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"), * @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"),
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"), * @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
@@ -55,7 +58,8 @@ class ProviderProfile extends Model
'certifications', 'certifications',
'facilities', 'facilities',
'business_hours', 'business_hours',
'is_verified', 'verification_status',
'rejection_reason',
'rating', 'rating',
'website', 'website',
'social_media', 'social_media',
@@ -64,6 +68,21 @@ class ProviderProfile extends Model
'is_active' 'is_active'
]; ];
protected $casts = [
'verification_status' => VerificationStatus::class,
];
/**
* API 相容層:既有前端與 Swagger 讀取 is_verified boolean
* 欄位移除後以 accessor 維持輸出(= 審核通過)
*/
protected $appends = ['is_verified'];
public function getIsVerifiedAttribute(): bool
{
return $this->verification_status === VerificationStatus::Approved;
}
/** /**
* 與用戶的關聯 * 與用戶的關聯
*/ */
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
protected $fillable = [
'diving_offer_id',
'member_id',
'rating',
'comment',
'helpful_count',
'is_edited',
];
protected $casts = [
'rating' => 'integer',
'helpful_count' => 'integer',
'is_edited' => 'boolean',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
public function edit()
{
return $this->hasOne(ReviewEdit::class);
}
public function votes()
{
return $this->hasMany(ReviewVote::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewEdit extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'old_rating',
'old_comment',
'edited_at',
];
protected $casts = [
'old_rating' => 'integer',
'edited_at' => 'datetime',
];
public function review()
{
return $this->belongsTo(Review::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewVote extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'member_id',
'created_at',
];
public function review()
{
return $this->belongsTo(Review::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
//
}
+8
View File
@@ -140,6 +140,14 @@ class User extends Authenticatable
return $this->hasOne(ProviderProfile::class); return $this->hasOne(ProviderProfile::class);
} }
/**
* 教練驗證證照圖片(送審用)
*/
public function providerCertifications()
{
return $this->hasMany(ProviderCertification::class);
}
/** /**
* 獲取用戶的會員資料 * 獲取用戶的會員資料
*/ */
@@ -0,0 +1,74 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCancelledNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(
public readonly Booking $booking,
public readonly string $cancelledBy,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->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 團隊');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCompletedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->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 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingConfirmedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->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 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCreatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->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 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingRejectedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->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 團隊');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use App\Models\BookingMessage;
use App\Models\User;
use Illuminate\Notifications\Notification;
// 不走 Queue:通知需要在 HTTP response 前同步寫進 DB
// 讓後續的 NotificationCreated broadcast 能立刻讓前端拉到正確的 unread count。
class NewBookingMessageNotification extends Notification
{
public function __construct(
public readonly BookingMessage $message,
public readonly Booking $booking,
public readonly User $sender,
) {}
public function via(object $notifiable): array
{
// 聊天訊息通知只寫進 DB,不寄信(太頻繁)
return ['database'];
}
public function toArray(object $notifiable): array
{
// 顯示名稱:教練加前綴,學員直接用名字
$senderLabel = $this->sender->role === 'provider'
? "教練 {$this->sender->name}"
: $this->sender->name;
$preview = $this->message->type === 'image'
? '傳送了一張圖片'
: mb_strimwidth($this->message->content, 0, 50, '…');
// 依收件方角色決定跳轉路徑
$actionUrl = $notifiable->role === 'provider'
? config('app.frontend_url') . '/coach/bookings'
: config('app.frontend_url') . '/my-bookings';
return [
'type' => 'new_message',
'title' => "{$senderLabel} 傳來新訊息",
'body' => $preview,
'action_url' => $actionUrl,
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ProviderVerificationApprovedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'verification_approved',
'title' => '審核通過',
'body' => '恭喜!你的教練資格已通過平台審核,課程現在會公開曝光並可接受預約。',
'action_url' => config('app.frontend_url') . '/coach/dashboard',
'related_id' => $notifiable->id,
'related_type' => 'provider_verification',
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('教練資格審核通過 — CFDivePlatform')
->greeting('恭喜!')
->line('你的教練資格已通過平台審核。')
->line('你的課程現在會公開曝光,並可開始接受會員預約。')
->action('前往教練後台', config('app.frontend_url') . '/coach/dashboard');
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ProviderVerificationRejectedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly string $reason) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'verification_rejected',
'title' => '審核未通過',
'body' => "你的教練資格審核未通過。原因:{$this->reason}",
'action_url' => config('app.frontend_url') . '/coach/verification',
'related_id' => $notifiable->id,
'related_type' => 'provider_verification',
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('教練資格審核結果 — CFDivePlatform')
->greeting('審核結果通知')
->line('很抱歉,你的教練資格審核未通過。')
->line("原因:{$this->reason}")
->line('你可以補正資料與證照後重新送審。')
->action('重新送審', config('app.frontend_url') . '/coach/verification');
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Notifications;
use App\Models\Review;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class ReviewReceivedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Review $review) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
$offer = $this->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',
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Traits;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
trait CompressesImages
{
/**
* 壓縮上傳圖片並存入 public disk,回傳相對路徑。
* 參數與聊天圖片管線一致(scaleDown 2048 + JPEG quality 85),
* 確保平台內影像處理行為單一來源。
*/
private function compressToJpeg(UploadedFile $file, string $directory): string
{
$manager = new ImageManager(new Driver());
$image = $manager->read($file);
if ($image->width() > 2048 || $image->height() > 2048) {
$image->scaleDown(width: 2048, height: 2048);
}
$path = trim($directory, '/') . '/' . Str::uuid() . '.jpg';
Storage::disk('public')->put($path, $image->toJpeg(quality: 85));
return $path;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Traits;
trait NormalizesEmail
{
private function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
}
+6 -2
View File
@@ -9,11 +9,15 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// $middleware->trustProxies(at: ['*']);
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// \Sentry\Laravel\Integration::handles($exceptions);
})->create(); })->create();
+27
View File
@@ -0,0 +1,27 @@
services:
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: cfdive-phpmyadmin
restart: unless-stopped
ports:
- "127.0.0.1:8081:80"
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USERNAME:-cfdiveuser}
PMA_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
networks:
- cfdive-network
depends_on:
db:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
ports:
- "127.0.0.1:8025:8025"
networks:
- cfdive-network
+5 -1
View File
@@ -7,10 +7,14 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"darkaonline/l5-swagger": "^9.0", "darkaonline/l5-swagger": "^9.0",
"intervention/image": "^3.11",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/reverb": "^1.10",
"laravel/sanctum": "^4.1", "laravel/sanctum": "^4.1",
"laravel/socialite": "^5.20", "laravel/socialite": "^5.20",
"laravel/tinker": "^2.9" "laravel/tinker": "^2.9",
"predis/predis": "^3.4",
"sentry/sentry-laravel": "^4.25"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
Generated
+1599 -4
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'Laravel'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | Application Environment
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
];
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
+6 -2
View File
@@ -17,9 +17,13 @@ return [
'paths' => ['api/*', 'sanctum/csrf-cookie'], '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' => [], 'allowed_origins_patterns' => [],
+1 -1
View File
@@ -5,7 +5,7 @@ return [
'documentations' => [ 'documentations' => [
'default' => [ 'default' => [
'api' => [ 'api' => [
'title' => 'L5 Swagger UI', 'title' => 'CFDive Platform API',
], ],
'routes' => [ 'routes' => [
+102
View File
@@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_SERVER_HOSTNAME', null),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];
+1 -1
View File
@@ -47,7 +47,7 @@ return [
| |
*/ */
'expiration' => null, 'expiration' => 60 * 24 * 7,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Sentry Laravel SDK configuration file.
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// @see https://spotlightjs.com/
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
'release' => env('SENTRY_RELEASE'),
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
'environment' => env('SENTRY_ENVIRONMENT'),
// Override the organization ID used for trace continuation checks.
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
// 'ignore_exceptions' => [],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
'ignore_transactions' => [
// Ignore Laravel's default health URL
'/up',
],
// Breadcrumb specific configuration
'breadcrumbs' => [
// Capture Laravel logs as breadcrumbs
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
// Capture Livewire components like routes as breadcrumbs
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
// Capture SQL queries as breadcrumbs
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
// Capture queue job information as breadcrumbs
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
// Capture command information as breadcrumbs
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
// Capture HTTP client request information as breadcrumbs
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture send notifications as breadcrumbs
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
],
// Performance monitoring specific configuration
'tracing' => [
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
// Capture SQL queries as spans
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query spans
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
// Capture where the SQL query originated from on the SQL query spans
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
// Define a threshold in milliseconds for SQL queries to resolve their origin
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
// Capture views rendered as spans
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
// Capture Livewire components as spans
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
// Capture HTTP client requests as spans
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as spans
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
// Capture where the Redis command originated from on the Redis command spans
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
// Capture send notifications as spans
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
// Enable tracing for requests without a matching route (404's)
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
// Enable the tracing integrations supplied by Sentry (recommended)
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
],
];
@@ -19,7 +19,7 @@ return new class extends Migration
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間 $table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
$table->string('password'); // 密碼 $table->string('password'); // 密碼
$table->string('phone')->nullable(); // 電話號碼,可為空 $table->string('phone')->nullable(); // 電話號碼,可為空
$table->enum('role', ['admin', 'coach', 'member'])->default('member'); // 角色:管理員、教練、會員 $table->enum('role', ['admin', 'coach', 'member', 'provider'])->default('member'); // 角色:管理員、教練、會員、服務提供者
$table->boolean('is_active')->default(true); // 是否啟用 $table->boolean('is_active')->default(true); // 是否啟用
$table->rememberToken(); // 記住我 token $table->rememberToken(); // 記住我 token
$table->timestamps(); // 建立與更新時間 $table->timestamps(); // 建立與更新時間
@@ -12,11 +12,17 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
if (DB::getDriverName() === 'sqlite') {
return;
}
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'"); DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
} }
public function down(): void public function down(): void
{ {
if (DB::getDriverName() === 'sqlite') {
return;
}
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'"); DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
} }
}; };
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
$table->date('scheduled_date');
$table->time('start_time');
$table->unsignedInteger('max_participants');
$table->unsignedInteger('current_participants')->default(0);
$table->string('status')->default('open');
$table->timestamps();
$table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date');
$table->index('provider_id', 'idx_provider_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('course_schedules');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->unsignedInteger('participants')->default(1);
$table->unsignedInteger('total_price');
$table->string('status')->default('pending');
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['member_id', 'status'], 'idx_member_status');
$table->index(['schedule_id', 'status'], 'idx_schedule_status');
$table->index(['status', 'created_at'], 'idx_status_created_at');
$table->index(['status', 'schedule_id'], 'idx_status_sched');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->tinyInteger('rating')->unsigned();
$table->text('comment');
$table->unsignedInteger('helpful_count')->default(0);
$table->boolean('is_edited')->default(false);
$table->timestamps();
$table->unique(['member_id', 'diving_offer_id']);
$table->index(['diving_offer_id', 'helpful_count'], 'idx_reviews_helpful');
$table->index(['diving_offer_id', 'rating'], 'idx_reviews_rating');
$table->index(['diving_offer_id', 'created_at'], 'idx_reviews_newest');
$table->index(['member_id', 'diving_offer_id'], 'idx_reviews_member_offer');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reviews');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_edits', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->unique()->constrained('reviews')->cascadeOnDelete();
$table->tinyInteger('old_rating')->unsigned();
$table->text('old_comment');
$table->timestamp('edited_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_edits');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_votes', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->constrained('reviews')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['review_id', 'member_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_votes');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('cover_image', 500)->nullable()->after('description');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropColumn('cover_image');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_images', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->string('image_path', 500);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('course_images');
}
};

Some files were not shown because too many files have changed in this diff Show More