Compare commits
17 Commits
master
...
4feeeff94a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4feeeff94a | |||
| e8cd82cdee | |||
| 849c33e83a | |||
| 0455c1577e | |||
| 6877ef30b5 | |||
| 03f8caf3e9 | |||
| 4baa4cb52b | |||
| 81a9f84b26 | |||
| 975b56ca54 | |||
| ad2c05779d | |||
| da48a3652d | |||
| 550e2fc97a | |||
| 725c86f434 | |||
| 9de698d90e | |||
| b02a18d762 | |||
| 0a0d335304 | |||
| 6fef9e8c37 |
+5
-4
@@ -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
|
||||||
@@ -36,17 +37,17 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=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
|
||||||
|
|||||||
@@ -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,3 +18,11 @@ yarn-error.log
|
|||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
|
/.claude
|
||||||
|
/.opensp
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
/frontend/node_modules
|
||||||
|
/frontend/dist
|
||||||
|
/frontend/.env
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "CFDivePlatform API Tests",
|
||||||
|
"description": "CFDivePlatform API 測試集合 - 包含會員、服務提供者、管理員等完整測試案例",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "基礎測試",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Ping Test",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/ping",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "ping"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test POST",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"測試用戶\",\n \"value\": \"123\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/testpost",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "testpost"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "會員認證",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "會員註冊",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"測試會員\",\n \"email\": \"member@test.com\",\n \"password\": \"password123\",\n \"password_confirmation\": \"password123\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/member/register",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "member", "register"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "會員登入",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"email\": \"member@test.com\",\n \"password\": \"password123\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/member/login",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "member", "login"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "取得會員資料",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{member_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/member/profile",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "member", "profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "會員專屬 API",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "會員登出",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{member_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/member/logout",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "member", "logout"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "更新會員個人資料",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{member_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"更新後的會員名稱\",\n \"phone\": \"0912345678\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/member/profile",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "member", "profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "修改會員密碼",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{member_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"current_password\": \"password123\",\n \"new_password\": \"newpassword123\",\n \"new_password_confirmation\": \"newpassword123\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/member/change-password",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "member", "change-password"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "服務提供者認證",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "服務提供者註冊",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"測試教練\",\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/provider/register",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "provider", "register"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "服務提供者登入",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/provider/login",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "provider", "login"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "服務提供者專屬 API",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "服務提供者登出",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{provider_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/provider/logout",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "provider", "logout"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "取得服務提供者資料",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{provider_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/provider/profile",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "provider", "profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "管理員認證",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "管理員註冊",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"測試管理員\",\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/admin/register",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "admin", "register"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "管理員登入",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/admin/login",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "admin", "login"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "管理員專屬 API",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "查詢會員資料",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{admin_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/admin/check-member/1",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "admin", "check-member", "1"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "查詢服務提供者資料",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{admin_token}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "http://localhost:8080/api/admin/check-provider/1",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": ["localhost"],
|
||||||
|
"port": "8080",
|
||||||
|
"path": ["api", "admin", "check-provider", "1"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"value": "http://localhost:8080/api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "member_token",
|
||||||
|
"value": "",
|
||||||
|
"description": "會員登入後設定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider_token",
|
||||||
|
"value": "",
|
||||||
|
"description": "服務提供者登入後設定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "admin_token",
|
||||||
|
"value": "",
|
||||||
|
"description": "管理員登入後設定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test_member_email",
|
||||||
|
"value": "member@test.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test_provider_email",
|
||||||
|
"value": "provider@test.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test_admin_email",
|
||||||
|
"value": "admin@test.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test_password",
|
||||||
|
"value": "password123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
# 使用官方 PHP 8.2 FPM 鏡像作為基礎
|
||||||
|
FROM php:8.2-fpm
|
||||||
|
|
||||||
|
# 安裝系統依賴
|
||||||
|
# 1. 更新套件列表並安裝必要的套件
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libpng-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
libzip-dev \
|
||||||
|
default-mysql-client \
|
||||||
|
netcat-openbsd \
|
||||||
|
grep \
|
||||||
|
cron
|
||||||
|
|
||||||
|
# 清理 apt 快取以減小鏡像大小
|
||||||
|
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安裝 PHP 擴展
|
||||||
|
# 這些擴展是 Laravel 和一般 PHP 開發所需的
|
||||||
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||||
|
|
||||||
|
# 從官方 Composer 鏡像複製 Composer 執行文件
|
||||||
|
# 這樣可以確保我們使用最新版本的 Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# 設置工作目錄
|
||||||
|
WORKDIR /var/www
|
||||||
|
|
||||||
|
# 設置目錄權限
|
||||||
|
# 將 /var/www 目錄的所有權更改為 www-data 用戶和組
|
||||||
|
RUN chown -R www-data:www-data /var/www
|
||||||
|
|
||||||
|
# 創建必要的目錄並設置權限
|
||||||
|
# Laravel 需要這些目錄來存儲日誌、緩存等
|
||||||
|
RUN mkdir -p /var/www/storage /var/www/bootstrap/cache
|
||||||
|
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||||
|
|
||||||
|
# 複製自定義的 PHP 配置文件
|
||||||
|
# 這個文件包含 PHP 運行時的配置選項
|
||||||
|
COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
|
||||||
|
|
||||||
|
# 複製並設置入口點腳本
|
||||||
|
# 這個腳本將在容器啟動時執行
|
||||||
|
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# 加入 Laravel Scheduler cron job
|
||||||
|
RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \
|
||||||
|
> /etc/cron.d/laravel-scheduler \
|
||||||
|
&& chmod 0644 /etc/cron.d/laravel-scheduler \
|
||||||
|
&& crontab /etc/cron.d/laravel-scheduler
|
||||||
|
|
||||||
|
# 設置容器啟動時執行的入口點
|
||||||
|
# 這將在 CMD 指令之前執行
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
# 設置默認的容器命令
|
||||||
|
# 這將在 ENTRYPOINT 執行後運行
|
||||||
|
CMD ["php-fpm"]
|
||||||
@@ -1,66 +1,49 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# CFDive Platform
|
||||||
|
|
||||||
<p align="center">
|
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
---
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
## 功能概覽
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
**會員(Member)**
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
- 註冊 / 登入(Email + Google OAuth)
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
- 瀏覽、搜尋、篩選潛水課程
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
- 查看課程時段並送出預約
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
- 對完成的課程留下評價(支援匿名、有幫助投票)
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
- 站內通知(Email + Polling)
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
**教練(Provider)**
|
||||||
|
- 課程 CRUD(含封面 + 相簿圖片上傳)
|
||||||
|
- 課程時段管理
|
||||||
|
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||||
|
|
||||||
## Learning Laravel
|
**管理員(Admin)**
|
||||||
|
- 平台統計數據(會員數、教練數、課程數)
|
||||||
|
- 會員與教練帳號管理(啟用 / 停用 / 審核)
|
||||||
|
- 課程、預約、評價管理
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
---
|
||||||
|
|
||||||
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
|
## 技術棧
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
| 層級 | 技術 |
|
||||||
|
|------|------|
|
||||||
|
| 後端 | PHP 8.x / Laravel 11 |
|
||||||
|
| 前端 | Vue 3 |
|
||||||
|
| 資料庫 | MySQL 8.0 |
|
||||||
|
| 快取 | Redis(predis)|
|
||||||
|
| 認證 | Laravel Sanctum + Google OAuth |
|
||||||
|
| 容器 | Docker / Docker Compose |
|
||||||
|
| API 文件 | Swagger UI(l5-swagger)|
|
||||||
|
|
||||||
## Laravel Sponsors
|
---
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
## API 文件
|
||||||
|
|
||||||
### Premium Partners
|
共 73 個端點,涵蓋:
|
||||||
|
- 認證(Email + Google OAuth)
|
||||||
- **[Vehikl](https://vehikl.com/)**
|
- 公開課程查詢
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
- 會員預約 / 評價 / 通知
|
||||||
- **[WebReinvent](https://webreinvent.com/)**
|
- 教練課程 / 時段 / 預約管理
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
- 管理員後台
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
|
|
||||||
- **[Cyber-Duck](https://cyber-duck.co.uk)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Jump24](https://jump24.co.uk)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel/)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
- **[byte5](https://byte5.de)**
|
|
||||||
- **[OP.GG](https://op.gg)**
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
<?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\Put(
|
||||||
|
* path="/admin/providers/{id}/toggle-verified",
|
||||||
|
* summary="切換教練審核狀態",
|
||||||
|
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
|
||||||
|
* operationId="toggleProviderVerified",
|
||||||
|
* 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_verified", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @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 toggleProviderVerified()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認教練存在
|
||||||
|
*
|
||||||
|
* @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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-21
@@ -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",
|
||||||
@@ -207,7 +211,7 @@ class AuthApiDoc
|
|||||||
* 會員登出
|
* 會員登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/member",
|
* path="/member/logout",
|
||||||
* summary="會員登出",
|
* summary="會員登出",
|
||||||
* description="會員登出系統並撤銷當前令牌",
|
* description="會員登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutMember",
|
* operationId="logoutMember",
|
||||||
@@ -241,7 +245,7 @@ class AuthApiDoc
|
|||||||
* 取得會員個人資料
|
* 取得會員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/member",
|
* path="/member/profile",
|
||||||
* summary="取得會員個人資料",
|
* summary="取得會員個人資料",
|
||||||
* description="取得當前登入會員的個人資料",
|
* description="取得當前登入會員的個人資料",
|
||||||
* operationId="memberProfile",
|
* operationId="memberProfile",
|
||||||
@@ -303,7 +307,7 @@ class AuthApiDoc
|
|||||||
* 更新會員個人資料
|
* 更新會員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/member",
|
* path="/member/profile",
|
||||||
* summary="更新會員個人資料",
|
* summary="更新會員個人資料",
|
||||||
* description="更新當前登入會員的個人資料",
|
* description="更新當前登入會員的個人資料",
|
||||||
* operationId="updateMemberProfile",
|
* operationId="updateMemberProfile",
|
||||||
@@ -383,8 +387,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改會員密碼
|
* 修改會員密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/member",
|
* path="/member/change-password",
|
||||||
* summary="修改會員密碼",
|
* summary="修改會員密碼",
|
||||||
* description="修改當前登入會員的密碼",
|
* description="修改當前登入會員的密碼",
|
||||||
* operationId="changeMemberPassword",
|
* operationId="changeMemberPassword",
|
||||||
@@ -444,7 +448,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者註冊
|
* 服務提供者註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/provider",
|
* path="/provider/register",
|
||||||
* summary="服務提供者註冊",
|
* summary="服務提供者註冊",
|
||||||
* description="建立新的服務提供者帳號",
|
* description="建立新的服務提供者帳號",
|
||||||
* operationId="registerProvider",
|
* operationId="registerProvider",
|
||||||
@@ -498,7 +502,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者登入
|
* 服務提供者登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/provider",
|
* path="/provider/login",
|
||||||
* summary="服務提供者登入",
|
* summary="服務提供者登入",
|
||||||
* description="服務提供者帳號登入系統",
|
* description="服務提供者帳號登入系統",
|
||||||
* operationId="loginProvider",
|
* operationId="loginProvider",
|
||||||
@@ -580,7 +584,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者登出
|
* 服務提供者登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/provider",
|
* path="/provider/logout",
|
||||||
* summary="服務提供者登出",
|
* summary="服務提供者登出",
|
||||||
* description="服務提供者登出系統並撤銷當前令牌",
|
* description="服務提供者登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutProvider",
|
* operationId="logoutProvider",
|
||||||
@@ -614,7 +618,7 @@ class AuthApiDoc
|
|||||||
* 取得服務提供者資料
|
* 取得服務提供者資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/provider",
|
* path="/provider/profile",
|
||||||
* summary="取得服務提供者資料",
|
* summary="取得服務提供者資料",
|
||||||
* description="取得當前登入服務提供者的資料",
|
* description="取得當前登入服務提供者的資料",
|
||||||
* operationId="providerProfile",
|
* operationId="providerProfile",
|
||||||
@@ -678,7 +682,7 @@ class AuthApiDoc
|
|||||||
* 更新服務提供者資料
|
* 更新服務提供者資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/provider",
|
* path="/provider/profile",
|
||||||
* summary="更新服務提供者資料",
|
* summary="更新服務提供者資料",
|
||||||
* description="更新當前登入服務提供者的資料",
|
* description="更新當前登入服務提供者的資料",
|
||||||
* operationId="updateProviderProfile",
|
* operationId="updateProviderProfile",
|
||||||
@@ -764,8 +768,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改服務提供者密碼
|
* 修改服務提供者密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/provider",
|
* path="/provider/change-password",
|
||||||
* summary="修改服務提供者密碼",
|
* summary="修改服務提供者密碼",
|
||||||
* description="修改當前登入服務提供者的密碼",
|
* description="修改當前登入服務提供者的密碼",
|
||||||
* operationId="changeProviderPassword",
|
* operationId="changeProviderPassword",
|
||||||
@@ -825,7 +829,7 @@ class AuthApiDoc
|
|||||||
* 管理員註冊
|
* 管理員註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/admin",
|
* path="/admin/register",
|
||||||
* summary="管理員註冊",
|
* summary="管理員註冊",
|
||||||
* description="建立新的管理員帳號",
|
* description="建立新的管理員帳號",
|
||||||
* operationId="registerAdmin",
|
* operationId="registerAdmin",
|
||||||
@@ -877,7 +881,7 @@ class AuthApiDoc
|
|||||||
* 管理員登入
|
* 管理員登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/admin",
|
* path="/admin/login",
|
||||||
* summary="管理員登入",
|
* summary="管理員登入",
|
||||||
* description="管理員帳號登入系統",
|
* description="管理員帳號登入系統",
|
||||||
* operationId="loginAdmin",
|
* operationId="loginAdmin",
|
||||||
@@ -958,7 +962,7 @@ class AuthApiDoc
|
|||||||
* 管理員登出
|
* 管理員登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/admin",
|
* path="/admin/logout",
|
||||||
* summary="管理員登出",
|
* summary="管理員登出",
|
||||||
* description="管理員登出系統並撤銷當前令牌",
|
* description="管理員登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutAdmin",
|
* operationId="logoutAdmin",
|
||||||
@@ -992,7 +996,7 @@ class AuthApiDoc
|
|||||||
* 取得管理員個人資料
|
* 取得管理員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/admin",
|
* path="/admin/profile",
|
||||||
* summary="取得管理員個人資料",
|
* summary="取得管理員個人資料",
|
||||||
* description="取得當前登入管理員的個人資料",
|
* description="取得當前登入管理員的個人資料",
|
||||||
* operationId="adminProfile",
|
* operationId="adminProfile",
|
||||||
@@ -1030,7 +1034,7 @@ class AuthApiDoc
|
|||||||
* 更新管理員個人資料
|
* 更新管理員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/admin",
|
* path="/admin/profile",
|
||||||
* summary="更新管理員個人資料",
|
* summary="更新管理員個人資料",
|
||||||
* description="更新當前登入管理員的個人資料",
|
* description="更新當前登入管理員的個人資料",
|
||||||
* operationId="updateAdminProfile",
|
* operationId="updateAdminProfile",
|
||||||
@@ -1087,8 +1091,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改管理員密碼
|
* 修改管理員密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/admin",
|
* path="/admin/change-password",
|
||||||
* summary="修改管理員密碼",
|
* summary="修改管理員密碼",
|
||||||
* description="修改當前登入管理員的密碼",
|
* description="修改當前登入管理員的密碼",
|
||||||
* operationId="changeAdminPassword",
|
* operationId="changeAdminPassword",
|
||||||
@@ -1143,4 +1147,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()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="會員預約",
|
||||||
|
* description="會員的預約管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="會員評價",
|
||||||
|
* description="會員的評價管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="通知",
|
||||||
|
* description="站內通知管理(Member / Provider 共用)"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<?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="服務提供者的預約管理"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class ProviderApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ScheduleStatus: string
|
||||||
|
{
|
||||||
|
case Open = 'open';
|
||||||
|
case Full = 'full';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
}
|
||||||
@@ -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,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\DivingOffer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminOfferController extends Controller
|
||||||
|
{
|
||||||
|
private function checkAdmin()
|
||||||
|
{
|
||||||
|
if (auth()->user()->role !== 'admin') {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$query = DivingOffer::query();
|
||||||
|
|
||||||
|
if ($q = $request->query('q')) {
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('title', 'like', "%{$q}%")
|
||||||
|
->orWhere('location', 'like', "%{$q}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginated = $query->latest('id')->paginate(15);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $paginated->items(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $paginated->total(),
|
||||||
|
'per_page' => $paginated->perPage(),
|
||||||
|
'current_page' => $paginated->currentPage(),
|
||||||
|
'last_page' => $paginated->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $id)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$offer = DivingOffer::find($id);
|
||||||
|
if (!$offer) {
|
||||||
|
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$offer->delete();
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\DivingOffer;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class AdminStatsController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if (auth()->user()->role !== 'admin') {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = Cache::remember('admin_stats', 300, fn() => [
|
||||||
|
'total_members' => User::where('role', 'member')->count(),
|
||||||
|
'total_providers' => User::where('role', 'provider')->count(),
|
||||||
|
'total_offers' => DivingOffer::count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $stats]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminUserController extends Controller
|
||||||
|
{
|
||||||
|
private function checkAdmin()
|
||||||
|
{
|
||||||
|
if (auth()->user()->role !== 'admin') {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUser(int $id, string $role)
|
||||||
|
{
|
||||||
|
return User::where('id', $id)->where('role', $role)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function members(Request $request)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$query = User::where('role', 'member')->with('memberProfile');
|
||||||
|
|
||||||
|
if ($q = $request->query('q')) {
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('name', 'like', "%{$q}%")
|
||||||
|
->orWhere('email', 'like', "%{$q}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginated = $query->latest()->paginate(15);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $paginated->items(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $paginated->total(),
|
||||||
|
'per_page' => $paginated->perPage(),
|
||||||
|
'current_page' => $paginated->currentPage(),
|
||||||
|
'last_page' => $paginated->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function member(int $id)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$user = $this->findUser($id, 'member');
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $user->load('memberProfile')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleMemberActive(int $id)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$user = $this->findUser($id, 'member');
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->is_active = !$user->is_active;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
|
||||||
|
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providers(Request $request)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$query = User::where('role', 'provider')->with('providerProfile');
|
||||||
|
|
||||||
|
if ($q = $request->query('q')) {
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('name', 'like', "%{$q}%")
|
||||||
|
->orWhere('email', 'like', "%{$q}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginated = $query->latest()->paginate(15);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $paginated->items(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $paginated->total(),
|
||||||
|
'per_page' => $paginated->perPage(),
|
||||||
|
'current_page' => $paginated->currentPage(),
|
||||||
|
'last_page' => $paginated->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provider(int $id)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$user = $this->findUser($id, 'provider');
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $user->load('providerProfile')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleProviderActive(int $id)
|
||||||
|
{
|
||||||
|
if ($err = $this->checkAdmin()) return $err;
|
||||||
|
|
||||||
|
$user = $this->findUser($id, 'provider');
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->is_active = !$user->is_active;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
|
||||||
|
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleProviderVerified(int $id)
|
||||||
|
{
|
||||||
|
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]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -474,7 +474,7 @@ class AuthController extends Controller
|
|||||||
'email' => 'required|string|email|max:255|unique:users',
|
'email' => 'required|string|email|max:255|unique:users',
|
||||||
'password' => 'required|string|min:6|confirmed',
|
'password' => 'required|string|min:6|confirmed',
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
'business_name' => 'required|string|max:255',
|
'business_name' => 'nullable|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
'contact_person' => 'nullable|string|max:100',
|
'contact_person' => 'nullable|string|max:100',
|
||||||
'contact_phone' => 'nullable|string|max:20',
|
'contact_phone' => 'nullable|string|max:20',
|
||||||
@@ -655,6 +655,12 @@ class AuthController extends Controller
|
|||||||
'contact_email' => 'nullable|string|email|max:255',
|
'contact_email' => 'nullable|string|email|max:255',
|
||||||
'address' => 'nullable|string|max:255',
|
'address' => 'nullable|string|max:255',
|
||||||
'business_hours' => 'nullable|string|max:100',
|
'business_hours' => 'nullable|string|max:100',
|
||||||
|
'certifications' => 'nullable|string',
|
||||||
|
'dive_sites' => 'nullable|string',
|
||||||
|
'services' => 'nullable|string',
|
||||||
|
'facilities' => 'nullable|string',
|
||||||
|
'website' => 'nullable|string|max:255',
|
||||||
|
'social_media' => 'nullable|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
@@ -701,7 +707,25 @@ class AuthController extends Controller
|
|||||||
if ($request->has('business_hours')) {
|
if ($request->has('business_hours')) {
|
||||||
$providerProfile->business_hours = $request->business_hours;
|
$providerProfile->business_hours = $request->business_hours;
|
||||||
}
|
}
|
||||||
|
if ($request->has('certifications')) {
|
||||||
|
$providerProfile->certifications = $request->certifications;
|
||||||
|
}
|
||||||
|
if ($request->has('dive_sites')) {
|
||||||
|
$providerProfile->dive_sites = $request->dive_sites;
|
||||||
|
}
|
||||||
|
if ($request->has('services')) {
|
||||||
|
$providerProfile->services = $request->services;
|
||||||
|
}
|
||||||
|
if ($request->has('facilities')) {
|
||||||
|
$providerProfile->facilities = $request->facilities;
|
||||||
|
}
|
||||||
|
if ($request->has('website')) {
|
||||||
|
$providerProfile->website = $request->website;
|
||||||
|
}
|
||||||
|
if ($request->has('social_media')) {
|
||||||
|
$providerProfile->social_media = $request->social_media;
|
||||||
|
}
|
||||||
|
|
||||||
$providerProfile->save();
|
$providerProfile->save();
|
||||||
|
|
||||||
// 加載服務提供者資料
|
// 加載服務提供者資料
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CourseImage;
|
||||||
|
use App\Models\DivingOffer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CourseImageController extends Controller
|
||||||
|
{
|
||||||
|
private function validateImage(Request $request): void
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = $request->file('image')->store("offers/{$offerId}/cover", 'public');
|
||||||
|
$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 = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
|
||||||
|
$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' => '圖片已刪除']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\DivingOffer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class DivingOfferController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$perPage = min((int) $request->query('per_page', 12), 50);
|
||||||
|
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
|
||||||
|
|
||||||
|
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
||||||
|
$query = DivingOffer::query();
|
||||||
|
|
||||||
|
if ($q = $request->query('q')) {
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('title', 'like', "%{$q}%")
|
||||||
|
->orWhere('location', 'like', "%{$q}%")
|
||||||
|
->orWhere('spot', 'like', "%{$q}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($region = $request->query('region')) {
|
||||||
|
$query->where('region', $region);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tag = $request->query('tag')) {
|
||||||
|
$query->where('tag', 'like', "%{$tag}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginated = $query->paginate($perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $paginated->total(),
|
||||||
|
'per_page' => $paginated->perPage(),
|
||||||
|
'current_page' => $paginated->currentPage(),
|
||||||
|
'last_page' => $paginated->lastPage(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $result['items'],
|
||||||
|
'meta' => $result['meta'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
$offer = DivingOffer::with('courseImages')->find($id);
|
||||||
|
|
||||||
|
if (!$offer) {
|
||||||
|
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,170 @@
|
|||||||
|
<?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')->findOrFail($data['schedule_id']);
|
||||||
|
|
||||||
|
// Layer 1:快速失敗
|
||||||
|
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 2:lockForUpdate 後二次驗證
|
||||||
|
$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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\DivingOffer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class ProviderOfferController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$offers = DivingOffer::where('provider_id', auth()->id())
|
||||||
|
->paginate(12);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $offers->items(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $offers->total(),
|
||||||
|
'per_page' => $offers->perPage(),
|
||||||
|
'current_page' => $offers->currentPage(),
|
||||||
|
'last_page' => $offers->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
$offer = DivingOffer::find($id);
|
||||||
|
|
||||||
|
if (!$offer) {
|
||||||
|
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($offer->provider_id !== auth()->id()) {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權限查看此課程'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $offer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'location' => 'required|string|max:255',
|
||||||
|
'spot' => 'nullable|string|max:255',
|
||||||
|
'price' => 'required|integer|min:0',
|
||||||
|
'region' => 'required|string|max:100',
|
||||||
|
'tag' => 'nullable|string|max:100',
|
||||||
|
'badges' => 'nullable|array',
|
||||||
|
'badges.*' => 'string|max:50',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['provider_id'] = auth()->id();
|
||||||
|
$validated['rating'] = 0;
|
||||||
|
$validated['reviews'] = 0;
|
||||||
|
|
||||||
|
$offer = DivingOffer::create($validated);
|
||||||
|
|
||||||
|
Cache::tags(['diving_offers'])->flush();
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $offer], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$offer = DivingOffer::find($id);
|
||||||
|
|
||||||
|
if (!$offer) {
|
||||||
|
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($offer->provider_id !== auth()->id()) {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權限修改此課程'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'nullable|string|max:255',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'spot' => 'nullable|string|max:255',
|
||||||
|
'price' => 'nullable|integer|min:0',
|
||||||
|
'region' => 'nullable|string|max:100',
|
||||||
|
'tag' => 'nullable|string|max:100',
|
||||||
|
'badges' => 'nullable|array',
|
||||||
|
'badges.*' => 'string|max:50',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$offer->fill($validated)->save();
|
||||||
|
|
||||||
|
Cache::tags(['diving_offers'])->flush();
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $offer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $id)
|
||||||
|
{
|
||||||
|
$offer = DivingOffer::find($id);
|
||||||
|
|
||||||
|
if (!$offer) {
|
||||||
|
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($offer->provider_id !== auth()->id()) {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權限刪除此課程'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$offer->delete();
|
||||||
|
|
||||||
|
Cache::tags(['diving_offers'])->flush();
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'message' => '課程已刪除']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::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::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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,24 +105,10 @@ 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);
|
||||||
$user->load('memberProfile');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'status' => true,
|
|
||||||
'message' => 'Google 登入成功',
|
|
||||||
'data' => [
|
|
||||||
'user' => $user,
|
|
||||||
'token' => $token,
|
|
||||||
'token_type' => 'Bearer',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
|
||||||
'status' => false,
|
|
||||||
'message' => 'Google 登入失敗:' . $e->getMessage()
|
|
||||||
], 500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,23 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
/**
|
// 該模型已棄用,請使用ProviderProfile模型
|
||||||
* @OA\Schema(
|
|
||||||
* schema="CoachProfile",
|
|
||||||
* title="教練個人資料",
|
|
||||||
* description="教練的詳細個人資料",
|
|
||||||
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
|
|
||||||
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
|
|
||||||
* @OA\Property(property="bio", type="string", example="專業潛水教練,擁有10年教學經驗", description="個人簡介"),
|
|
||||||
* @OA\Property(property="expertise", type="string", example="自由潛水,水肺潛水", description="專長領域"),
|
|
||||||
* @OA\Property(property="certification", type="string", example="PADI專業潛水教練", description="證照資訊"),
|
|
||||||
* @OA\Property(property="experience", type="string", example="10年教學經驗,帶領超過500名學員", description="教學經驗"),
|
|
||||||
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
|
|
||||||
* @OA\Property(property="availability", type="string", example="週一至週五 09:00-18:00", description="可授課時間"),
|
|
||||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="創建時間"),
|
|
||||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="更新時間")
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
class CoachProfile extends Model
|
class CoachProfile extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class DivingOffer extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $table = 'diving_offers';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'provider_id',
|
||||||
|
'title',
|
||||||
|
'location',
|
||||||
|
'spot',
|
||||||
|
'rating',
|
||||||
|
'reviews',
|
||||||
|
'price',
|
||||||
|
'badges',
|
||||||
|
'description',
|
||||||
|
'tag',
|
||||||
|
'region',
|
||||||
|
'cover_image',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'badges' => 'array',
|
||||||
|
'rating' => 'float',
|
||||||
|
'price' => '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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
/**
|
/**
|
||||||
* @OA\Schema(
|
* @OA\Schema(
|
||||||
* schema="ProviderProfile",
|
* schema="ProviderProfile",
|
||||||
* title="潛水業者資料",
|
* title="服務提供者資料",
|
||||||
* description="潛水業者的詳細資料",
|
* description="服務提供者的詳細資料",
|
||||||
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
|
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
|
||||||
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
|
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
|
||||||
* @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"),
|
* @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
//
|
$middleware->alias([
|
||||||
|
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"laravel/framework": "^11.0",
|
"laravel/framework": "^11.0",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+67
-4
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "cd7975455a8ac2a316ac819eaed700e8",
|
"content-hash": "c7d653b10f8ee748e5662242029651ce",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -3242,6 +3242,69 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-12-14T21:12:59+00:00"
|
"time": "2024-12-14T21:12:59+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "predis/predis",
|
||||||
|
"version": "v3.4.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/predis/predis.git",
|
||||||
|
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075",
|
||||||
|
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0",
|
||||||
|
"psr/http-message": "^1.0|^2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.3",
|
||||||
|
"phpstan/phpstan": "^1.9",
|
||||||
|
"phpunit/phpcov": "^6.0 || ^8.0",
|
||||||
|
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Predis\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Till Krüss",
|
||||||
|
"homepage": "https://till.im",
|
||||||
|
"role": "Maintainer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
|
||||||
|
"homepage": "http://github.com/predis/predis",
|
||||||
|
"keywords": [
|
||||||
|
"nosql",
|
||||||
|
"predis",
|
||||||
|
"redis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/predis/predis/issues",
|
||||||
|
"source": "https://github.com/predis/predis/tree/v3.4.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/sponsors/tillkruss",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-09T20:33:04+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/cache",
|
"name": "psr/cache",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@@ -9166,12 +9229,12 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.3.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
|
||||||
|
'allowed_origins' => [
|
||||||
|
env('FRONTEND_URL', 'http://localhost:5173'),
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
'http://localhost:5173',
|
||||||
|
],
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => false,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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->unsignedBigInteger('provider_id')->nullable()->after('id');
|
||||||
|
$table->foreign('provider_id')->references('id')->on('users')->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('diving_offers', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['provider_id']);
|
||||||
|
$table->dropColumn('provider_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('spot')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('diving_offers', function (Blueprint $table) {
|
||||||
|
$table->string('spot')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('jobs', function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedTinyInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('notifications', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('type');
|
||||||
|
$table->morphs('notifiable');
|
||||||
|
$table->text('data');
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->text('connection');
|
||||||
|
$table->text('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('notifications', function (Blueprint $table) {
|
||||||
|
$table->index(['notifiable_type', 'notifiable_id', 'read_at'], 'notifications_notifiable_read_at_index');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('diving_offers', function (Blueprint $table) {
|
||||||
|
$table->index('provider_id', 'diving_offers_provider_id_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('notifications', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('notifications_notifiable_read_at_index');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('diving_offers', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('diving_offers_provider_id_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\BookingStatus;
|
||||||
|
use App\Enums\ScheduleStatus;
|
||||||
|
use App\Models\AdminProfile;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\CourseSchedule;
|
||||||
|
use App\Models\DivingOffer;
|
||||||
|
use App\Models\MemberProfile;
|
||||||
|
use App\Models\ProviderProfile;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class DevelopmentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Admin
|
||||||
|
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
|
||||||
|
'name' => '平台管理員', 'password' => Hash::make('password123'),
|
||||||
|
'role' => 'admin', 'is_active' => true,
|
||||||
|
]);
|
||||||
|
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
|
||||||
|
|
||||||
|
// Coach
|
||||||
|
$coach = User::firstOrCreate(['email' => 'coach@cfdive.com'], [
|
||||||
|
'name' => '蔡教練', 'password' => Hash::make('password123'),
|
||||||
|
'role' => 'provider', 'is_active' => true,
|
||||||
|
]);
|
||||||
|
ProviderProfile::firstOrCreate(['user_id' => $coach->id], [
|
||||||
|
'business_name' => '藍海潛水工作室',
|
||||||
|
'description' => '專業 PADI 認證教練,10 年教學經驗',
|
||||||
|
'contact_phone' => '0912345678',
|
||||||
|
'contact_email' => 'coach@cfdive.com',
|
||||||
|
'is_verified' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Member
|
||||||
|
$member = User::firstOrCreate(['email' => 'member@cfdive.com'], [
|
||||||
|
'name' => '測試會員', 'password' => Hash::make('password123'),
|
||||||
|
'role' => 'member', 'is_active' => true,
|
||||||
|
]);
|
||||||
|
MemberProfile::firstOrCreate(['user_id' => $member->id], ['gender' => 'male']);
|
||||||
|
|
||||||
|
// Offers
|
||||||
|
$offer = DivingOffer::firstOrCreate(
|
||||||
|
['title' => '潛入海底 — 入門體驗', 'provider_id' => $coach->id],
|
||||||
|
[
|
||||||
|
'location' => '墾丁', 'spot' => '南灣', 'price' => 6000,
|
||||||
|
'region' => '南部', 'tag' => '初學者',
|
||||||
|
'badges' => ['PADI認證', '含裝備'],
|
||||||
|
'description' => '適合零基礎的水肺潛水入門課程,由專業教練全程陪同。',
|
||||||
|
'rating' => 0, 'reviews' => 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
DivingOffer::firstOrCreate(
|
||||||
|
['title' => '進階深潛探索', 'provider_id' => $coach->id],
|
||||||
|
[
|
||||||
|
'location' => '小琉球', 'spot' => '美人洞', 'price' => 9800,
|
||||||
|
'region' => '南部', 'tag' => '進階',
|
||||||
|
'badges' => ['AOW認證', '含住宿'],
|
||||||
|
'description' => '探索 30 米深海,適合已有 OW 認證的潛水愛好者。',
|
||||||
|
'rating' => 0, 'reviews' => 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 未來時段(開放預約)
|
||||||
|
$futureSchedule = CourseSchedule::firstOrCreate(
|
||||||
|
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->addDays(14)->toDateString()],
|
||||||
|
[
|
||||||
|
'provider_id' => $coach->id, 'start_time' => '09:00',
|
||||||
|
'max_participants' => 5, 'current_participants' => 0,
|
||||||
|
'status' => ScheduleStatus::Open,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 過去時段(供測試 completed booking)
|
||||||
|
$pastSchedule = CourseSchedule::firstOrCreate(
|
||||||
|
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->subDays(7)->toDateString()],
|
||||||
|
[
|
||||||
|
'provider_id' => $coach->id, 'start_time' => '09:00',
|
||||||
|
'max_participants' => 5, 'current_participants' => 1,
|
||||||
|
'status' => ScheduleStatus::Open,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pending booking(未來)
|
||||||
|
Booking::firstOrCreate(
|
||||||
|
['schedule_id' => $futureSchedule->id, 'member_id' => $member->id],
|
||||||
|
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Pending]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Completed booking(可評價)
|
||||||
|
Booking::firstOrCreate(
|
||||||
|
['schedule_id' => $pastSchedule->id, 'member_id' => $member->id],
|
||||||
|
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->command->info('✅ Seed 完成');
|
||||||
|
$this->command->info(' Admin: admin@cfdive.com / password123');
|
||||||
|
$this->command->info(' Coach: coach@cfdive.com / password123');
|
||||||
|
$this->command->info(' Member: member@cfdive.com / password123');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
# PHP-FPM 服務
|
||||||
|
app:
|
||||||
|
# 構建配置
|
||||||
|
build:
|
||||||
|
context: . # 使用當前目錄作為構建上下文
|
||||||
|
dockerfile: Dockerfile # 指定 Dockerfile 路徑
|
||||||
|
image: cfdive-platform # 構建的鏡像名稱
|
||||||
|
container_name: cfdive-app # 容器名稱
|
||||||
|
restart: unless-stopped # 自動重啟策略:除非手動停止,否則自動重啟
|
||||||
|
working_dir: /var/www/ # 工作目錄
|
||||||
|
|
||||||
|
# 卷掛載:將本地代碼掛載到容器中
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www # 本地代碼目錄掛載到容器中的 /var/www
|
||||||
|
|
||||||
|
# 環境變數:設置 Laravel 數據庫連接
|
||||||
|
environment:
|
||||||
|
- DB_CONNECTION=mysql # 數據庫類型
|
||||||
|
- DB_HOST=db # 數據庫主機名
|
||||||
|
- DB_PORT=3306 # 數據庫端口
|
||||||
|
- DB_DATABASE=CFDivePlatform # 數據庫名稱
|
||||||
|
- DB_USERNAME=cfdiveuser # 數據庫用戶名
|
||||||
|
- DB_PASSWORD=cfdivepass # 數據庫密碼
|
||||||
|
|
||||||
|
# 網絡配置
|
||||||
|
networks:
|
||||||
|
- cfdive-network # 連接到自定義網絡
|
||||||
|
- proxy_net
|
||||||
|
|
||||||
|
# 依賴關係:確保 db 和 redis 服務先啟動
|
||||||
|
depends_on:
|
||||||
|
db: # 依賴 db 服務
|
||||||
|
condition: service_healthy # 等待數據庫健康檢查通過
|
||||||
|
redis: # 依賴 redis 服務
|
||||||
|
condition: service_started # 等待服務啟動
|
||||||
|
|
||||||
|
# 健康檢查配置
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
|
||||||
|
interval: 30s # 每30秒檢查一次
|
||||||
|
timeout: 10s # 超時時間10秒
|
||||||
|
retries: 3 # 重試3次
|
||||||
|
start_period: 40s # 容器啟動後40秒開始檢查
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: cfdive-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
|
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
- proxy_net
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
|
||||||
|
image: cfdive-frontend
|
||||||
|
container_name: cfdive-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
- proxy_net
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: cfdive-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: CFDivePlatform
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_USER: cfdiveuser
|
||||||
|
MYSQL_PASSWORD: cfdivepass
|
||||||
|
SERVICE_TAGS: dev
|
||||||
|
SERVICE_NAME: mysql
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "cfdiveuser", "-pcfdivepass"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin
|
||||||
|
container_name: cfdive-phpmyadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PMA_HOST: db
|
||||||
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: cfdiveuser
|
||||||
|
PMA_PASSWORD: cfdivepass
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
queue-worker:
|
||||||
|
image: cfdive-platform
|
||||||
|
container_name: cfdive-queue
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /var/www/
|
||||||
|
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
|
environment:
|
||||||
|
- DB_CONNECTION=mysql
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_DATABASE=CFDivePlatform
|
||||||
|
- DB_USERNAME=cfdiveuser
|
||||||
|
- DB_PASSWORD=cfdivepass
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit
|
||||||
|
container_name: cfdive-mailpit
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
container_name: cfdive-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cfdive-network:
|
||||||
|
driver: bridge
|
||||||
|
proxy_net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 定義一個 HTTP 服務器塊
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name cfdive.local localhost;
|
||||||
|
|
||||||
|
# 默認索引文件,按順序嘗試
|
||||||
|
index index.php index.html;
|
||||||
|
|
||||||
|
# 錯誤日誌和訪問日誌的路徑
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
|
||||||
|
# 網站根目錄,指向 Laravel 的 public 目錄
|
||||||
|
root /var/www/public;
|
||||||
|
|
||||||
|
# 客戶端上傳文件大小限制
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# 處理所有請求
|
||||||
|
location / {
|
||||||
|
# 嘗試以 URI 作為文件查找,然後作為目錄,最後轉發到 index.php
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
|
||||||
|
# 啟用靜態 gzip 文件服務(如果存在 .gz 文件)
|
||||||
|
gzip_static on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 處理 PHP 文件請求
|
||||||
|
location ~ \.php$ {
|
||||||
|
# 如果文件不存在,返回 404
|
||||||
|
try_files $uri =404;
|
||||||
|
|
||||||
|
# 分割路徑信息,用於處理 PATH_INFO
|
||||||
|
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||||
|
|
||||||
|
# 轉發 PHP 請求到 PHP-FPM 服務
|
||||||
|
fastcgi_pass app:9000;
|
||||||
|
|
||||||
|
# 設置 FastCGI 緩衝區大小
|
||||||
|
fastcgi_buffers 16 16k;
|
||||||
|
fastcgi_buffer_size 32k;
|
||||||
|
|
||||||
|
# 默認索引文件
|
||||||
|
fastcgi_index index.php;
|
||||||
|
|
||||||
|
# 設置 FastCGI 讀取超時時間(秒)
|
||||||
|
fastcgi_read_timeout 600;
|
||||||
|
|
||||||
|
# 包含 FastCGI 參數
|
||||||
|
include fastcgi_params;
|
||||||
|
|
||||||
|
# 設置 SCRIPT_FILENAME 參數,告訴 PHP-FPM 要執行的文件
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
|
||||||
|
# 設置 PATH_INFO 參數,用於獲取路徑信息
|
||||||
|
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||||
|
|
||||||
|
# 檢查目錄結構
|
||||||
|
if [ ! -d "/var/www/storage" ]; then
|
||||||
|
echo "創建 storage 目錄..."
|
||||||
|
mkdir -p /var/www/storage
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "/var/www/bootstrap/cache" ]; then
|
||||||
|
echo "創建 bootstrap/cache 目錄..."
|
||||||
|
mkdir -p /var/www/bootstrap/cache
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 設置權限
|
||||||
|
echo "設置目錄權限..."
|
||||||
|
chown -R www-data:www-data /var/www
|
||||||
|
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||||
|
|
||||||
|
# 等待 MySQL 服務啟動
|
||||||
|
echo "等待 MySQL 服務啟動..."
|
||||||
|
|
||||||
|
# 使用更穩定的方法檢查 MySQL 連接
|
||||||
|
MAX_TRIES=60
|
||||||
|
COUNT=0
|
||||||
|
|
||||||
|
wait_for_mysql() {
|
||||||
|
while [ $COUNT -lt $MAX_TRIES ]; do
|
||||||
|
if mysqladmin ping -h"db" -u"cfdiveuser" -p"cfdivepass" --silent 2>/dev/null; then
|
||||||
|
echo "✅ MySQL 服務已準備就緒"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 備用檢查方法
|
||||||
|
if php -r "
|
||||||
|
try {
|
||||||
|
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', 'cfdivepass');
|
||||||
|
echo 'PHP-PDO-OK';
|
||||||
|
exit(0);
|
||||||
|
} catch(Exception \$e) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
" 2>/dev/null; then
|
||||||
|
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
|
||||||
|
sleep 2
|
||||||
|
COUNT=$((COUNT+1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $COUNT -eq $MAX_TRIES ]; then
|
||||||
|
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_mysql
|
||||||
|
|
||||||
|
# 檢查並安裝 Composer 依賴
|
||||||
|
echo "📦 檢查 Composer 依賴..."
|
||||||
|
if [ -f "composer.json" ]; then
|
||||||
|
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
|
||||||
|
echo "安裝 Composer 依賴..."
|
||||||
|
composer install --no-scripts --no-autoloader --optimize-autoloader
|
||||||
|
composer dump-autoload --optimize
|
||||||
|
else
|
||||||
|
echo "✅ Composer 依賴已是最新"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 設置 Laravel 環境
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "🔧 創建 .env 檔案..."
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
else
|
||||||
|
echo "✅ .env 檔案已存在"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 更新環境變數以確保正確配置
|
||||||
|
echo "🔧 更新資料庫配置..."
|
||||||
|
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
|
||||||
|
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=cfdivepass/g" .env
|
||||||
|
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
|
||||||
|
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
|
||||||
|
|
||||||
|
# 執行遷移(如果數據庫已準備好)
|
||||||
|
echo "🗄️ 執行數據庫遷移..."
|
||||||
|
if php artisan migrate:status 2>/dev/null; then
|
||||||
|
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
|
||||||
|
else
|
||||||
|
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 清除與優化 Laravel 緩存
|
||||||
|
echo "🧹 清除 Laravel 緩存..."
|
||||||
|
php artisan config:clear || true
|
||||||
|
php artisan cache:clear || true
|
||||||
|
php artisan route:clear || true
|
||||||
|
php artisan view:clear || true
|
||||||
|
|
||||||
|
# 生成 Swagger 文檔(如果可能)
|
||||||
|
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||||
|
echo "📖 生成 API 文檔..."
|
||||||
|
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ CFDivePlatform 初始化完成!"
|
||||||
|
|
||||||
|
# 建立 storage symlink
|
||||||
|
echo "🔗 建立 storage symlink..."
|
||||||
|
php artisan storage:link --force || true
|
||||||
|
|
||||||
|
# 啟動 cron daemon(Laravel Scheduler)
|
||||||
|
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||||
|
service cron start || cron || true
|
||||||
|
|
||||||
|
# 執行傳入的命令
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
upload_max_filesize=40M
|
||||||
|
post_max_size=40M
|
||||||
|
memory_limit=512M
|
||||||
|
max_execution_time=600
|
||||||
|
max_input_vars=10000
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VITE_API_URL=http://localhost:8080
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>cf-dive-frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
}
|
||||||
Generated
+3855
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "cf-dive-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.16.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import NavBar from './components/NavBar.vue'
|
||||||
|
import NotificationDrawer from './components/NotificationDrawer.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isBackofficePage = computed(() =>
|
||||||
|
route.path.startsWith('/coach') || route.path.startsWith('/admin')
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<NavBar v-if="!isBackofficePage" />
|
||||||
|
<RouterView />
|
||||||
|
<NotificationDrawer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const adminApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
adminApi.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export default adminApi
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import api from './axios'
|
||||||
|
|
||||||
|
export function getMyBookings() {
|
||||||
|
return api.get('/member/bookings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBooking(id) {
|
||||||
|
return api.get(`/member/bookings/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBooking(payload) {
|
||||||
|
return api.post('/member/bookings', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelBooking(id) {
|
||||||
|
return api.delete(`/member/bookings/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const coachApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
coachApi.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('coach_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export default coachApi
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import coachApi from './coachAxios'
|
||||||
|
|
||||||
|
export function getProviderBookings() {
|
||||||
|
return coachApi.get('/provider/bookings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmBooking(id) {
|
||||||
|
return coachApi.put(`/provider/bookings/${id}/confirm`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectBooking(id) {
|
||||||
|
return coachApi.put(`/provider/bookings/${id}/reject`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelBooking(id) {
|
||||||
|
return coachApi.put(`/provider/bookings/${id}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeBooking(id) {
|
||||||
|
return coachApi.put(`/provider/bookings/${id}/complete`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import coachApi from './coachAxios'
|
||||||
|
|
||||||
|
export function getSchedules() {
|
||||||
|
return coachApi.get('/provider/schedules')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSchedule(payload) {
|
||||||
|
return coachApi.post('/provider/schedules', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSchedule(id, payload) {
|
||||||
|
return coachApi.put(`/provider/schedules/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSchedule(id) {
|
||||||
|
return coachApi.delete(`/provider/schedules/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import coachApi from './coachAxios'
|
||||||
|
|
||||||
|
function toFormData(file) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('image', file)
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadCover(offerId, file) {
|
||||||
|
return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCover(offerId) {
|
||||||
|
return coachApi.delete(`/provider/offers/${offerId}/cover`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadImage(offerId, file) {
|
||||||
|
return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteImage(imageId) {
|
||||||
|
return coachApi.delete(`/provider/images/${imageId}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const notificationApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
notificationApi.interceptors.request.use((config) => {
|
||||||
|
// 優先用 coach_token,因為 coach 身份通知優先;member 也可用自己的 token
|
||||||
|
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
||||||
|
const isCoachPage = window.location.pathname.startsWith('/coach')
|
||||||
|
const token = isCoachPage
|
||||||
|
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
|
||||||
|
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export default notificationApi
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import api from './axios'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const publicApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getReviews(offerId, sort = 'helpful') {
|
||||||
|
return publicApi.get(`/diving-offers/${offerId}/reviews`, { params: { sort } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReview(payload) {
|
||||||
|
return api.post('/member/reviews', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateReview(id, payload) {
|
||||||
|
return api.put(`/member/reviews/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteReview(id) {
|
||||||
|
return api.delete(`/member/reviews/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleHelpful(reviewId) {
|
||||||
|
return api.post(`/reviews/${reviewId}/helpful`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const publicApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getSchedulesByOffer(offerId) {
|
||||||
|
return publicApi.get(`/diving-offers/${offerId}/schedules`)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAdminAuthStore } from '../stores/adminAuth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const adminAuth = useAdminAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await adminAuth.logout()
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="bg-slate-800 text-white shadow-md">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<span class="text-lg font-bold tracking-wide">⚙️ Admin Panel</span>
|
||||||
|
<RouterLink to="/admin/dashboard" class="text-sm hover:text-slate-300 transition">儀表板</RouterLink>
|
||||||
|
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
|
||||||
|
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
|
||||||
|
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
|
||||||
|
<RouterLink to="/admin/bookings" class="text-sm hover:text-slate-300 transition">預約管理</RouterLink>
|
||||||
|
<RouterLink to="/admin/reviews" class="text-sm hover:text-slate-300 transition">評價管理</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
|
||||||
|
<button @click="handleLogout"
|
||||||
|
class="bg-slate-600 hover:bg-slate-500 px-4 py-1.5 rounded-full transition">
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useCoachAuthStore } from '../stores/coachAuth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import NotificationBell from './NotificationBell.vue'
|
||||||
|
|
||||||
|
const coachAuth = useCoachAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await coachAuth.logout()
|
||||||
|
router.push('/coach/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="bg-gray-900 text-white shadow-md">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
|
||||||
|
🤿 Coach Portal
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
|
||||||
|
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
|
||||||
|
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
|
||||||
|
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
|
||||||
|
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
|
||||||
|
<NotificationBell />
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
|
||||||
|
>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
offer: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
:to="`/courses/${offer.id}`"
|
||||||
|
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<div class="h-40 overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="offer.cover_image_url"
|
||||||
|
:src="offer.cover_image_url"
|
||||||
|
:alt="offer.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
|
||||||
|
🤿
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 flex flex-col gap-2 flex-1">
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
v-for="badge in (offer.badges || [])"
|
||||||
|
:key="badge"
|
||||||
|
class="text-xs bg-ocean-100 text-ocean-700 px-2 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
<span v-if="offer.tag" class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||||
|
{{ offer.tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="font-semibold text-gray-800 line-clamp-2 leading-snug">{{ offer.title }}</h3>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 flex items-center gap-1">
|
||||||
|
📍 {{ offer.location }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-auto flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
|
<span class="text-sm text-amber-500 font-medium">
|
||||||
|
★ {{ offer.rating }} <span class="text-gray-400">({{ offer.reviews }})</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-ocean-700 font-bold">NT$ {{ offer.price.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import viteLogo from '../assets/vite.svg'
|
||||||
|
import heroImg from '../assets/hero.png'
|
||||||
|
import vueLogo from '../assets/vue.svg'
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="center">
|
||||||
|
<div class="hero">
|
||||||
|
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||||
|
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||||
|
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="counter" @click="count++">
|
||||||
|
Count is {{ count }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img class="logo" :src="viteLogo" alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img class="button-icon" :src="vueLogo" alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user