From 43720482c4e31aa48e4e93baf52a432f007b8560 Mon Sep 17 00:00:00 2001 From: Hank Date: Fri, 12 Jun 2026 00:26:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(verification):=20=E6=95=99=E7=B7=B4?= =?UTF-8?q?=E5=AF=A9=E6=A0=B8=E6=B5=81=E7=A8=8B=E5=AE=8C=E6=95=B4=E7=89=88?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=8B=80=E6=85=8B=E6=A9=9F/=E8=AD=89?= =?UTF-8?q?=E7=85=A7=E9=80=81=E5=AF=A9/Admin=20=E8=A3=81=E6=B1=BA/?= =?UTF-8?q?=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實作 openspec change provider-verification-workflow(25/25 tasks): - is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/ approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留 is_verified 相容輸出 - 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因 顯示與重送(/coach/verification 新頁面) - Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除 toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取 - 通知:審核通過/駁回(站內+Email,駁回含原因) - 可見性與預約入口改判 approved(行為等價) - 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8), 既有 4 個測試檔 helper 遷移;Swagger 同步 - 規格同步:provider-verification 全面改寫、admin-user-management toggle 段落改為移除聲明 容器內全套件 173 passed / 439 assertions。 Co-Authored-By: Claude Fable 5 --- app/Docs/AdminApiDoc.php | 81 +++- app/Enums/VerificationStatus.php | 23 ++ .../Controllers/API/AdminUserController.php | 21 +- .../API/AdminVerificationController.php | 94 +++++ .../API/MemberBookingController.php | 3 +- .../API/ProviderVerificationController.php | 113 ++++++ app/Models/DivingOffer.php | 4 +- app/Models/ProviderCertification.php | 31 ++ app/Models/ProviderProfile.php | 23 +- app/Models/User.php | 8 + ...oviderVerificationApprovedNotification.php | 42 +++ ...oviderVerificationRejectedNotification.php | 45 +++ ..._provider_profiles_verification_status.php | 39 ++ ...2_create_provider_certifications_table.php | 23 ++ database/seeders/DemoSeeder.php | 10 +- database/seeders/DevelopmentSeeder.php | 2 +- frontend/src/components/CoachNavBar.vue | 1 + frontend/src/router/index.js | 1 + frontend/src/views/admin/ProvidersView.vue | 112 +++++- frontend/src/views/coach/VerificationView.vue | 138 +++++++ .../.openspec.yaml | 2 + .../provider-verification-workflow/design.md | 57 +++ .../proposal.md | 26 ++ .../specs/admin-user-management/spec.md | 14 + .../specs/provider-verification/spec.md | 84 +++++ .../provider-verification-workflow/tasks.md | 47 +++ openspec/specs/admin-user-management/spec.md | 16 +- openspec/specs/provider-verification/spec.md | 142 ++++--- routes/api.php | 10 +- storage/api-docs/api-docs.json | 350 ++++++++++-------- tests/Feature/AdminVerificationTest.php | 151 ++++++++ tests/Feature/BookingLifecycleTest.php | 6 +- tests/Feature/BookingOversellTest.php | 2 +- tests/Feature/DivingOfferVisibilityTest.php | 10 +- tests/Feature/ProviderVerificationTest.php | 163 ++++++++ tests/Feature/ReviewTest.php | 2 +- 36 files changed, 1622 insertions(+), 274 deletions(-) create mode 100644 app/Enums/VerificationStatus.php create mode 100644 app/Http/Controllers/API/AdminVerificationController.php create mode 100644 app/Http/Controllers/API/ProviderVerificationController.php create mode 100644 app/Models/ProviderCertification.php create mode 100644 app/Notifications/ProviderVerificationApprovedNotification.php create mode 100644 app/Notifications/ProviderVerificationRejectedNotification.php create mode 100644 database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php create mode 100644 database/migrations/2026_06_12_000002_create_provider_certifications_table.php create mode 100644 frontend/src/views/coach/VerificationView.vue create mode 100644 openspec/changes/provider-verification-workflow/.openspec.yaml create mode 100644 openspec/changes/provider-verification-workflow/design.md create mode 100644 openspec/changes/provider-verification-workflow/proposal.md create mode 100644 openspec/changes/provider-verification-workflow/specs/admin-user-management/spec.md create mode 100644 openspec/changes/provider-verification-workflow/specs/provider-verification/spec.md create mode 100644 openspec/changes/provider-verification-workflow/tasks.md create mode 100644 tests/Feature/AdminVerificationTest.php create mode 100644 tests/Feature/ProviderVerificationTest.php diff --git a/app/Docs/AdminApiDoc.php b/app/Docs/AdminApiDoc.php index 69ceeb4..944d58a 100644 --- a/app/Docs/AdminApiDoc.php +++ b/app/Docs/AdminApiDoc.php @@ -283,30 +283,85 @@ class AdminApiDoc } /** - * 切換教練審核狀態 + * 教練審核佇列 * - * @OA\Put( - * path="/admin/providers/{id}/toggle-verified", - * summary="切換教練審核狀態", - * description="通過或撤銷教練審核,回傳新的 is_verified 狀態", - * operationId="toggleProviderVerified", + * @OA\Get( + * path="/admin/verifications", + * summary="教練審核佇列", + * description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL", + * operationId="adminVerificationIndex", * tags={"Admin 教練管理"}, * security={{"bearerAuth": {}}}, - * @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")), + * @OA\Parameter(name="status", in="query", required=false, description="unsubmitted / pending / approved / rejected / all", @OA\Schema(type="string", default="pending")), * @OA\Response( * response=200, - * description="切換成功", + * 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\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="user_id", type="integer", example=5), + * @OA\Property(property="name", type="string", example="王教練"), + * @OA\Property(property="email", type="string", example="coach@example.com"), + * @OA\Property(property="business_name", type="string", example="藍海潛水"), + * @OA\Property(property="verification_status", type="string", example="pending"), + * @OA\Property(property="rejection_reason", type="string", nullable=true, example=null), + * @OA\Property(property="certifications", type="array", @OA\Items( + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg") + * )) + * )) * ) * ), - * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), - * @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * ) */ - public function toggleProviderVerified() + public function adminVerificationIndex() + { + } + + /** + * 通過教練審核 + * + * @OA\Put( + * path="/admin/verifications/{userId}/approve", + * summary="通過教練審核", + * description="將 pending 教練轉為 approved,課程恢復公開曝光並通知教練", + * operationId="adminVerificationApprove", + * tags={"Admin 教練管理"}, + * security={{"bearerAuth": {}}}, + * @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")), + * @OA\Response(response=200, description="已通過審核"), + * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * @OA\Response(response=422, description="當前狀態無法通過審核", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * ) + */ + public function adminVerificationApprove() + { + } + + /** + * 駁回教練審核 + * + * @OA\Put( + * path="/admin/verifications/{userId}/reject", + * summary="駁回教練審核", + * description="駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練", + * operationId="adminVerificationReject", + * tags={"Admin 教練管理"}, + * security={{"bearerAuth": {}}}, + * @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")), + * @OA\RequestBody(required=true, @OA\JsonContent( + * required={"reason"}, + * @OA\Property(property="reason", type="string", maxLength=500, example="證照影像不清晰,請重新拍攝上傳") + * )), + * @OA\Response(response=200, description="已駁回"), + * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), + * @OA\Response(response=422, description="原因未填或狀態不可駁回", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) + * ) + */ + public function adminVerificationReject() { } diff --git a/app/Enums/VerificationStatus.php b/app/Enums/VerificationStatus.php new file mode 100644 index 0000000..cc2480f --- /dev/null +++ b/app/Enums/VerificationStatus.php @@ -0,0 +1,23 @@ + ['pending'], + 'pending' => ['approved', 'rejected'], + 'approved' => ['rejected'], // 撤銷驗證,原因必填 + 'rejected' => ['pending'], // 重新送審 + ]; + + public function canTransitionTo(self $newStatus): bool + { + return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []); + } +} diff --git a/app/Http/Controllers/API/AdminUserController.php b/app/Http/Controllers/API/AdminUserController.php index 9814760..2ab18e5 100644 --- a/app/Http/Controllers/API/AdminUserController.php +++ b/app/Http/Controllers/API/AdminUserController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; class AdminUserController extends Controller { @@ -132,23 +131,5 @@ class AdminUserController extends Controller 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(); - - // 驗證狀態影響公開課程列表的可見性,需立即讓快取失效 - Cache::tags(['diving_offers'])->flush(); - - $msg = $profile->is_verified ? '教練已驗證' : '已取消驗證'; - return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]); - } + // toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機 } diff --git a/app/Http/Controllers/API/AdminVerificationController.php b/app/Http/Controllers/API/AdminVerificationController.php new file mode 100644 index 0000000..5a2a82a --- /dev/null +++ b/app/Http/Controllers/API/AdminVerificationController.php @@ -0,0 +1,94 @@ +query('status', VerificationStatus::Pending->value); + + $query = User::where('role', 'provider') + ->with(['providerProfile', 'providerCertifications']) + ->whereHas('providerProfile'); + + if ($status !== 'all') { + $query->whereHas('providerProfile', fn($q) => $q->where('verification_status', $status)); + } + + $providers = $query->orderByDesc('updated_at')->get()->map(fn($u) => [ + 'user_id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + 'business_name' => $u->providerProfile->business_name, + 'verification_status' => $u->providerProfile->verification_status->value, + 'rejection_reason' => $u->providerProfile->rejection_reason, + 'certifications' => $u->providerCertifications->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(), + ]); + + return response()->json(['status' => true, 'data' => $providers]); + } + + public function approve(Request $request, int $userId) + { + $profile = $this->findProviderProfile($userId); + + if (!$profile->verification_status->canTransitionTo(VerificationStatus::Approved)) { + return response()->json(['status' => false, 'message' => '當前狀態無法通過審核'], 422); + } + + $profile->update(['verification_status' => VerificationStatus::Approved, 'rejection_reason' => null]); + + // 驗證狀態影響公開課程可見性,需立即讓快取失效 + Cache::tags(['diving_offers'])->flush(); + + $this->notify($profile->user, new ProviderVerificationApprovedNotification()); + + return response()->json(['status' => true, 'message' => '教練已通過審核']); + } + + public function reject(Request $request, int $userId) + { + $data = $request->validate(['reason' => 'required|string|max:500']); + + $profile = $this->findProviderProfile($userId); + + if (!$profile->verification_status->canTransitionTo(VerificationStatus::Rejected)) { + return response()->json(['status' => false, 'message' => '當前狀態無法駁回'], 422); + } + + $profile->update([ + 'verification_status' => VerificationStatus::Rejected, + 'rejection_reason' => $data['reason'], + ]); + + Cache::tags(['diving_offers'])->flush(); + + $this->notify($profile->user, new ProviderVerificationRejectedNotification($data['reason'])); + + return response()->json(['status' => true, 'message' => '已駁回,教練將收到原因通知']); + } + + private function findProviderProfile(int $userId) + { + return User::where('role', 'provider')->findOrFail($userId)->providerProfile()->firstOrFail(); + } + + private function notify(User $provider, object $notification): void + { + try { + $provider->notify($notification); + } catch (\Throwable $e) { + Log::error('ProviderVerification notification failed: ' . $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/API/MemberBookingController.php b/app/Http/Controllers/API/MemberBookingController.php index 7276f72..5f5fab2 100644 --- a/app/Http/Controllers/API/MemberBookingController.php +++ b/app/Http/Controllers/API/MemberBookingController.php @@ -48,9 +48,10 @@ class MemberBookingController extends Controller $schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']); // Layer 1:快速失敗 - // 可見性繞過防護:課程屬未驗證教練時不可建立新預約(既有預約不受影響,見 provider-verification 規格) + // 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格) $offer = $schedule->divingOffer; if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) { + // is_verified 為 accessor(= verification_status === approved) return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422); } if ($schedule->status !== ScheduleStatus::Open) { diff --git a/app/Http/Controllers/API/ProviderVerificationController.php b/app/Http/Controllers/API/ProviderVerificationController.php new file mode 100644 index 0000000..a77774b --- /dev/null +++ b/app/Http/Controllers/API/ProviderVerificationController.php @@ -0,0 +1,113 @@ +user()->providerProfile; + + return response()->json([ + 'status' => true, + 'data' => [ + 'verification_status' => $profile->verification_status->value, + 'rejection_reason' => $profile->rejection_reason, + 'certifications' => $request->user()->providerCertifications + ->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(), + ], + ]); + } + + public function uploadCertification(Request $request) + { + $request->validate([ + 'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240', + ]); + + if ($err = $this->ensureCertificationsEditable($request)) { + return $err; + } + + $user = $request->user(); + + if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) { + return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422); + } + + $path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications"); + + $certification = ProviderCertification::create([ + 'user_id' => $user->id, + 'image_path' => $path, + ]); + + return response()->json([ + 'status' => true, + 'message' => '證照已上傳', + 'data' => ['id' => $certification->id, 'url' => $certification->url], + ], 201); + } + + public function deleteCertification(Request $request, int $id) + { + $certification = ProviderCertification::findOrFail($id); + + if ($certification->user_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403); + } + + if ($err = $this->ensureCertificationsEditable($request)) { + return $err; + } + + $certification->delete(); + + return response()->json(['status' => true, 'message' => '證照已刪除']); + } + + public function submit(Request $request) + { + $user = $request->user(); + $profile = $user->providerProfile; + + if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) { + return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422); + } + + if ($user->providerCertifications()->count() < 1) { + return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422); + } + + $profile->update([ + 'verification_status' => VerificationStatus::Pending, + 'rejection_reason' => null, + ]); + + return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']); + } + + /** + * 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動 + */ + private function ensureCertificationsEditable(Request $request) + { + $status = $request->user()->providerProfile->verification_status; + + if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) { + return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422); + } + + return null; + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index b552b52..7791ad8 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -55,14 +55,14 @@ class DivingOffer extends Model } /** - * 公開端點可見性:未驗證教練的課程不對外曝光。 + * 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。 * provider_id 為 null 的課程(平台自有資料)不受此限制。 */ public function scopeVisibleToPublic(Builder $query): Builder { return $query->where(function (Builder $visible) { $visible->whereNull('provider_id') - ->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('is_verified', true)); + ->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value)); }); } diff --git a/app/Models/ProviderCertification.php b/app/Models/ProviderCertification.php new file mode 100644 index 0000000..59fb0aa --- /dev/null +++ b/app/Models/ProviderCertification.php @@ -0,0 +1,31 @@ +delete($certification->image_path); + }); + } + + public function getUrlAttribute(): string + { + return Storage::disk('public')->url($this->image_path); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php index 79250dd..ce14e97 100644 --- a/app/Models/ProviderProfile.php +++ b/app/Models/ProviderProfile.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\VerificationStatus; use Illuminate\Database\Eloquent\Model; /** @@ -23,7 +24,9 @@ use Illuminate\Database\Eloquent\Model; * @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"), * @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"), * @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"), - * @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證"), + * @OA\Property(property="verification_status", type="string", example="approved", description="審核狀態:unsubmitted / pending / approved / rejected"), + * @OA\Property(property="rejection_reason", type="string", nullable=true, example=null, description="駁回原因(rejected 時有值)"), + * @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證(相容欄位,= verification_status 為 approved)"), * @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"), * @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"), * @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"), @@ -55,7 +58,8 @@ class ProviderProfile extends Model 'certifications', 'facilities', 'business_hours', - 'is_verified', + 'verification_status', + 'rejection_reason', 'rating', 'website', 'social_media', @@ -64,6 +68,21 @@ class ProviderProfile extends Model 'is_active' ]; + protected $casts = [ + 'verification_status' => VerificationStatus::class, + ]; + + /** + * API 相容層:既有前端與 Swagger 讀取 is_verified boolean, + * 欄位移除後以 accessor 維持輸出(= 審核通過) + */ + protected $appends = ['is_verified']; + + public function getIsVerifiedAttribute(): bool + { + return $this->verification_status === VerificationStatus::Approved; + } + /** * 與用戶的關聯 */ diff --git a/app/Models/User.php b/app/Models/User.php index 971ee8e..de38cb9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -140,6 +140,14 @@ class User extends Authenticatable return $this->hasOne(ProviderProfile::class); } + /** + * 教練驗證證照圖片(送審用) + */ + public function providerCertifications() + { + return $this->hasMany(ProviderCertification::class); + } + /** * 獲取用戶的會員資料 */ diff --git a/app/Notifications/ProviderVerificationApprovedNotification.php b/app/Notifications/ProviderVerificationApprovedNotification.php new file mode 100644 index 0000000..df5d798 --- /dev/null +++ b/app/Notifications/ProviderVerificationApprovedNotification.php @@ -0,0 +1,42 @@ + 'verification_approved', + 'title' => '審核通過', + 'body' => '恭喜!你的教練資格已通過平台審核,課程現在會公開曝光並可接受預約。', + 'action_url' => config('app.frontend_url') . '/coach/dashboard', + 'related_id' => $notifiable->id, + 'related_type' => 'provider_verification', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('教練資格審核通過 — CFDivePlatform') + ->greeting('恭喜!') + ->line('你的教練資格已通過平台審核。') + ->line('你的課程現在會公開曝光,並可開始接受會員預約。') + ->action('前往教練後台', config('app.frontend_url') . '/coach/dashboard'); + } +} diff --git a/app/Notifications/ProviderVerificationRejectedNotification.php b/app/Notifications/ProviderVerificationRejectedNotification.php new file mode 100644 index 0000000..a6452fa --- /dev/null +++ b/app/Notifications/ProviderVerificationRejectedNotification.php @@ -0,0 +1,45 @@ + 'verification_rejected', + 'title' => '審核未通過', + 'body' => "你的教練資格審核未通過。原因:{$this->reason}", + 'action_url' => config('app.frontend_url') . '/coach/verification', + 'related_id' => $notifiable->id, + 'related_type' => 'provider_verification', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('教練資格審核結果 — CFDivePlatform') + ->greeting('審核結果通知') + ->line('很抱歉,你的教練資格審核未通過。') + ->line("原因:{$this->reason}") + ->line('你可以補正資料與證照後重新送審。') + ->action('重新送審', config('app.frontend_url') . '/coach/verification'); + } +} diff --git a/database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php b/database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php new file mode 100644 index 0000000..8968a6a --- /dev/null +++ b/database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php @@ -0,0 +1,39 @@ +string('verification_status', 20)->default('unsubmitted')->after('is_verified') + ->comment('教練驗證狀態:unsubmitted / pending / approved / rejected'); + $table->text('rejection_reason')->nullable()->after('verification_status'); + }); + + // 既有資料轉換:已驗證視為審核通過,未驗證回到未送審 + DB::table('provider_profiles')->where('is_verified', true)->update(['verification_status' => 'approved']); + DB::table('provider_profiles')->where('is_verified', false)->update(['verification_status' => 'unsubmitted']); + + Schema::table('provider_profiles', function (Blueprint $table) { + $table->dropColumn('is_verified'); + }); + } + + public function down(): void + { + Schema::table('provider_profiles', function (Blueprint $table) { + $table->boolean('is_verified')->default(false); + }); + + DB::table('provider_profiles')->where('verification_status', 'approved')->update(['is_verified' => true]); + + Schema::table('provider_profiles', function (Blueprint $table) { + $table->dropColumn(['verification_status', 'rejection_reason']); + }); + } +}; diff --git a/database/migrations/2026_06_12_000002_create_provider_certifications_table.php b/database/migrations/2026_06_12_000002_create_provider_certifications_table.php new file mode 100644 index 0000000..667099f --- /dev/null +++ b/database/migrations/2026_06_12_000002_create_provider_certifications_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('教練 User id'); + $table->string('image_path')->comment('證照圖片相對路徑(public disk)'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('provider_certifications'); + } +}; diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index f5a97de..55f57ca 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -65,7 +65,7 @@ class DemoSeeder extends Seeder 'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影', 'certifications' => 'PADI 五星級潛水中心,PADI Course Director', 'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場', - 'business_hours' => '每日 07:30–18:00', 'is_verified' => true, + 'business_hours' => '每日 07:30–18:00', 'verification_status' => 'approved', ], [ 'email' => 'greendive@cfdive.com', 'name' => '陳美玲', @@ -77,7 +77,7 @@ class DemoSeeder extends Seeder 'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛', 'certifications' => 'PADI 潛水中心,SSI 認證教練', 'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄', - 'business_hours' => '每日 07:00–17:30', 'is_verified' => true, + 'business_hours' => '每日 07:00–17:30', 'verification_status' => 'approved', ], [ 'email' => 'islet@cfdive.com', 'name' => '張大偉', @@ -89,7 +89,7 @@ class DemoSeeder extends Seeder 'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽', 'certifications' => 'PADI 授權潛水中心', 'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務', - 'business_hours' => '每日 08:00–17:00', 'is_verified' => true, + 'business_hours' => '每日 08:00–17:00', 'verification_status' => 'approved', ], [ 'email' => 'northdive@cfdive.com', 'name' => '王建國', @@ -101,7 +101,7 @@ class DemoSeeder extends Seeder 'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門', 'certifications' => 'PADI 課程總監,PADI TecRec 教練', 'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修', - 'business_hours' => '週一公休,週二至週日 08:00–18:00', 'is_verified' => false, + 'business_hours' => '週一公休,週二至週日 08:00–18:00', 'verification_status' => 'pending', ], ]; @@ -123,7 +123,7 @@ class DemoSeeder extends Seeder 'certifications' => $data['certifications'], 'facilities' => $data['facilities'], 'business_hours' => $data['business_hours'], - 'is_verified' => $data['is_verified'], + 'verification_status' => $data['verification_status'], 'rating' => 0, ]); $providers[] = $user; diff --git a/database/seeders/DevelopmentSeeder.php b/database/seeders/DevelopmentSeeder.php index 56cd06c..75f5f0a 100644 --- a/database/seeders/DevelopmentSeeder.php +++ b/database/seeders/DevelopmentSeeder.php @@ -35,7 +35,7 @@ class DevelopmentSeeder extends Seeder 'description' => '專業 PADI 認證教練,10 年教學經驗', 'contact_phone' => '0912345678', 'contact_email' => 'coach@cfdive.com', - 'is_verified' => true, + 'verification_status' => 'approved', ]); // Member diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue index 8cee3f4..2e61540 100644 --- a/frontend/src/components/CoachNavBar.vue +++ b/frontend/src/components/CoachNavBar.vue @@ -23,6 +23,7 @@ async function handleLogout() { 時段管理 預約管理 課程評價 + 驗證申請 個人資料 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index d3080c6..fac7b8a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -30,6 +30,7 @@ const routes = [ { path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') }, { path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') }, { path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') }, + { path: 'verification', component: () => import('../views/coach/VerificationView.vue') }, ], }, diff --git a/frontend/src/views/admin/ProvidersView.vue b/frontend/src/views/admin/ProvidersView.vue index 27d7cec..026dd87 100644 --- a/frontend/src/views/admin/ProvidersView.vue +++ b/frontend/src/views/admin/ProvidersView.vue @@ -2,9 +2,24 @@ import { ref, onMounted } from 'vue' import adminApi from '../../api/adminAxios' -const providers = ref([]) -const loading = ref(true) -const search = ref('') +const providers = ref([]) +const loading = ref(true) +const search = ref('') +const pendingOnly = ref(false) +const certsOf = ref(null) // { name, certifications } 查看證照面板 +const rejectTarget = ref(null) // 駁回原因輸入面板的對象 +const rejectReason = ref('') + +const STATUS_META = { + unsubmitted: { label: '未送審', cls: 'bg-slate-100 text-slate-500' }, + pending: { label: '待審核', cls: 'bg-amber-100 text-amber-700' }, + approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700' }, + rejected: { label: '已駁回', cls: 'bg-rose-100 text-rose-600' }, +} + +function statusMeta(p) { + return STATUS_META[p.provider_profile?.verification_status] ?? STATUS_META.unsubmitted +} async function fetchProviders() { loading.value = true @@ -12,12 +27,19 @@ async function fetchProviders() { const params = {} if (search.value) params.q = search.value const res = await adminApi.get('/admin/providers', { params }) - providers.value = res.data.data + providers.value = pendingOnly.value + ? res.data.data.filter(p => p.provider_profile?.verification_status === 'pending') + : res.data.data } finally { loading.value = false } } +function togglePendingFilter() { + pendingOnly.value = !pendingOnly.value + fetchProviders() +} + async function toggleActive(p) { try { const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`) @@ -25,10 +47,32 @@ async function toggleActive(p) { } catch (e) { alert(e.response?.data?.message || '操作失敗') } } -async function toggleVerified(p) { +async function viewCertifications(p) { try { - const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`) - if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified + const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } }) + const row = res.data.data.find(v => v.user_id === p.id) + certsOf.value = { name: p.name, certifications: row?.certifications ?? [] } + } catch (e) { alert(e.response?.data?.message || '讀取失敗') } +} + +async function approve(p) { + try { + await adminApi.put(`/admin/verifications/${p.id}/approve`) + await fetchProviders() + } catch (e) { alert(e.response?.data?.message || '操作失敗') } +} + +function openReject(p) { + rejectTarget.value = p + rejectReason.value = '' +} + +async function confirmReject() { + if (!rejectReason.value.trim()) { alert('請填寫駁回原因'); return } + try { + await adminApi.put(`/admin/verifications/${rejectTarget.value.id}/reject`, { reason: rejectReason.value.trim() }) + rejectTarget.value = null + await fetchProviders() } catch (e) { alert(e.response?.data?.message || '操作失敗') } } @@ -44,6 +88,9 @@ onMounted(fetchProviders) class="flex-1 border border-slate-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" /> +
載入中...
@@ -67,9 +114,8 @@ onMounted(fetchProviders) {{ p.provider_profile?.business_name || '-' }} - - {{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }} + + {{ statusMeta(p).label }} @@ -79,11 +125,18 @@ onMounted(fetchProviders) -
- + +
+ + +
+
+

{{ certsOf.name }} 的證照

+

尚未上傳證照

+
+ + + +
+ +
+
+ + +
+
+

駁回 {{ rejectTarget.name }}

+

原因將以站內通知與 Email 告知教練

+ +
+ + +
+
+
diff --git a/frontend/src/views/coach/VerificationView.vue b/frontend/src/views/coach/VerificationView.vue new file mode 100644 index 0000000..cba08a9 --- /dev/null +++ b/frontend/src/views/coach/VerificationView.vue @@ -0,0 +1,138 @@ + + + diff --git a/openspec/changes/provider-verification-workflow/.openspec.yaml b/openspec/changes/provider-verification-workflow/.openspec.yaml new file mode 100644 index 0000000..e0c0898 --- /dev/null +++ b/openspec/changes/provider-verification-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-11 diff --git a/openspec/changes/provider-verification-workflow/design.md b/openspec/changes/provider-verification-workflow/design.md new file mode 100644 index 0000000..bdceb76 --- /dev/null +++ b/openspec/changes/provider-verification-workflow/design.md @@ -0,0 +1,57 @@ +# Design — provider-verification-workflow + +## D1:四狀態而非三狀態 + +路線圖原列 pending/approved/rejected 三態,但「註冊後補件送審」決策(Hank 2026-06-11 拍板)使「註冊完還沒送審」與「已送審待審」必須區分——Admin 佇列只該出現後者。故: + +``` +unsubmitted ──submit──> pending ──approve──> approved + ↑ │ │ + │ reject reject(撤銷,原因必填) + │ ↓ ↓ + └──(重新上傳)── rejected ──submit──> pending +``` + +- 合法轉移:`unsubmitted→pending`、`pending→approved|rejected`、`rejected→pending`、`approved→rejected`(撤銷) +- `rejected→pending` 重送時清空 `rejection_reason` +- 欄位用 string + 應用層常數(與 BookingStatus enum 同模式,建 `App\Enums\VerificationStatus`) + +## D2:取代而非並存 boolean + +`is_verified` 欄位**移除**(migration: true→approved、false→unsubmitted),不留同步欄位——兩個真實來源必然漂移(Rule 7)。API 相容由 `ProviderProfile::$appends` 的 `is_verified` accessor 提供(`= status === approved`),前端/Swagger 既有讀取點不破壞;查詢端(`visibleToPublic`、預約入口)改查 `verification_status`。 + +## D3:證照獨立資料表 + +`provider_certifications`(id, user_id, image_path, created_at)。不塞 JSON 欄位:要逐張刪除、要 FK 清理、上限 3 張需 count 查詢。圖片複用 `CompressesImages::compressToJpeg`(O3.1 的 trait,第二個使用者,驗證抽共用的價值),存 `providers/{user_id}/certifications/`。刪除政策:`unsubmitted`/`rejected` 可增刪;`pending`/`approved` 鎖定(審核依據不可變動)。 + +## D4:端點設計 + +**Provider(auth:sanctum + provider prefix)** +- `GET /api/provider/verification`——status、rejection_reason、certifications[] +- `POST /api/provider/verification/certifications`——上傳(≤3 張、≤10MB、壓縮);僅 unsubmitted/rejected +- `DELETE /api/provider/verification/certifications/{id}`——僅 unsubmitted/rejected、僅本人 +- `POST /api/provider/verification/submit`——至少 1 張證照;unsubmitted/rejected → pending + +**Admin(auth:sanctum + admin)** +- `GET /api/admin/verifications?status=pending`——佇列(預設 pending,可查全部狀態),含教練資料與證照 URL +- `PUT /api/admin/verifications/{userId}/approve`——pending → approved +- `PUT /api/admin/verifications/{userId}/reject`——pending/approved → rejected,`reason` 必填(max 500) + +approve/reject 皆 flush `diving_offers` 快取 tag(可見性立即生效,沿用 toggle 的既有做法)。`toggle-verified` 端點與 `AdminUserController::toggleProviderVerified` 移除——保留會提供繞過狀態機的後門。 + +控制器:新建 `ProviderVerificationController` 與 `AdminVerificationController`(單一職責),不塞進 AuthController/AdminUserController。 + +## D5:通知 + +`ProviderVerificationApprovedNotification`、`ProviderVerificationRejectedNotification`(含原因),照 Booking* 模式(database + mail channel,try/catch 包裹不阻斷主流程)。送審時**不**通知 Admin(Admin 有佇列頁;避免通知氾濫),列為未來選項。 + +## D6:前端 + +- **教練端** `views/coach/VerificationView.vue`(route `/coach/verification`):狀態卡(四態 + 駁回原因)、證照上傳/刪除(沿用 OfferFormView 的圖片上傳模式)、送審按鈕;CoachLayout 側欄加入口與狀態 badge +- **Admin 端** `views/admin/ProvidersView.vue` 擴充:狀態 badge 四態化、「待審核」filter、展開查看證照圖、通過/駁回操作(駁回原因用 inline 輸入,沿用該頁既有操作風格);不另開新頁(教練列表即審核入口,避免兩頁資料重複) + +## D7:Demo 資料與測試遷移 + +- DemoSeeder:3 位已驗證 → `approved`;1 位未驗證 → 給 1 張證照 + `pending`(展示審核佇列) +- 既有測試 helper(DivingOfferVisibility / BookingLifecycle / BookingOversell / ReviewTest):`is_verified => true/false` 改 `verification_status => approved/unsubmitted` +- 新測試:送審狀態機(含非法轉移)、證照上限與鎖定、Admin 審核權限邊界、通知發送、approve/reject 後可見性立即生效 diff --git a/openspec/changes/provider-verification-workflow/proposal.md b/openspec/changes/provider-verification-workflow/proposal.md new file mode 100644 index 0000000..86557e5 --- /dev/null +++ b/openspec/changes/provider-verification-workflow/proposal.md @@ -0,0 +1,26 @@ +## Why + +目前教練驗證只有一顆 Admin 的 `is_verified` 開關(2026-06-11 修補後具備可見性約束力),但**沒有審核「流程」**:教練無從提交資質證明、Admin 沒有審核依據與佇列、駁回無原因可言、結果無通知。路線圖(`docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1)將此列為金流前的信任基礎建設。 + +## What Changes + +- **資料模型**:`provider_profiles.is_verified`(boolean)升級為 `verification_status` 狀態機——`unsubmitted`(註冊預設)→ `pending`(已送審)→ `approved` / `rejected`(含 `rejection_reason`);新增 `provider_certifications` 資料表存證照圖片 +- **教練端**:後台新增「驗證申請」頁——上傳證照(1~3 張,複用 `CompressesImages` 壓縮管線)、送審、查看駁回原因、重新送審(產品決策:**註冊後補件送審**,不擋註冊流程) +- **Admin 端**:審核佇列(pending 優先)、查看證照、通過/駁回(駁回原因必填);移除舊 `toggle-verified` 開關端點(與狀態機衝突) +- **通知**:審核通過/駁回通知教練(站內 + Email,複用 notification 管線) +- **相容性**:`ProviderProfile` 以 accessor 保留 API 輸出的 `is_verified` 欄位(= status 是否為 approved);`visibleToPublic` scope 與預約入口檢查改查 `verification_status = 'approved'`,語意不變 + +## Capabilities + +### Modified Capabilities + +- `provider-verification`:boolean 語意升級為四狀態機;新增教練送審端點、Admin 審核端點、通知 requirements;可見性規則改以 `approved` 判定(行為等價) +- `admin-user-management`:`toggle-verified` 端點移除,由 approve / reject 取代 + +## Impact + +- **資料庫**:migration 轉換既有資料(`is_verified=true → approved`、`false → unsubmitted`)、新表 `provider_certifications` +- **後端**:新增 `ProviderVerificationController`、`AdminVerificationController`、2 個 Notification class;修改 `DivingOffer::visibleToPublic`、`MemberBookingController`、`AdminUserController`(移除 toggle)、`ProviderProfile`、兩個 Seeder +- **前端**:新增 `coach/VerificationView.vue`;改 `admin/ProvidersView.vue`(狀態 badge、審核操作) +- **測試**:既有用到 `is_verified` 的 4 個測試檔改用 `verification_status`;新增送審流程與審核權限測試 +- **行為變更**:Admin 不再能單鍵切換驗證,必須走審核流(撤銷 = 駁回既有 approved 教練並附原因);既有預約不受狀態變動影響(沿用 provider-verification 規格既定政策) diff --git a/openspec/changes/provider-verification-workflow/specs/admin-user-management/spec.md b/openspec/changes/provider-verification-workflow/specs/admin-user-management/spec.md new file mode 100644 index 0000000..368b2b6 --- /dev/null +++ b/openspec/changes/provider-verification-workflow/specs/admin-user-management/spec.md @@ -0,0 +1,14 @@ +## MODIFIED Requirements + +### Requirement: 管理員切換教練驗證狀態 +(本 requirement 由 provider-verification 的「Admin 審核佇列與裁決」取代) + +`PUT /api/admin/providers/{id}/toggle-verified` SHALL 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`。 + +#### Scenario: toggle 端點回 404 +- **WHEN** Admin 請求 `PUT /api/admin/providers/{id}/toggle-verified` +- **THEN** 回傳 HTTP 404 + +#### Scenario: 教練列表仍顯示驗證狀態 +- **WHEN** Admin 請求 `GET /api/admin/providers` +- **THEN** 每筆教練資料包含 `verification_status`(與相容用 `is_verified` accessor) diff --git a/openspec/changes/provider-verification-workflow/specs/provider-verification/spec.md b/openspec/changes/provider-verification-workflow/specs/provider-verification/spec.md new file mode 100644 index 0000000..0cd0742 --- /dev/null +++ b/openspec/changes/provider-verification-workflow/specs/provider-verification/spec.md @@ -0,0 +1,84 @@ +## ADDED Requirements + +### Requirement: 教練驗證狀態機 +`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending`、`approved`、`rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`(= status 為 approved)。 + +#### Scenario: 新教練註冊預設未送審 +- **WHEN** 教練完成註冊 +- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列 + +#### Scenario: 非法轉移被拒絕 +- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve +- **THEN** 回傳 HTTP 422,狀態不變 + +--- + +### Requirement: 教練上傳證照與送審 +教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定。送審需至少 1 張證照。 + +#### Scenario: 上傳證照 +- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張 +- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊 + +#### Scenario: 超過 3 張上限 +- **WHEN** 教練已有 3 張證照再上傳 +- **THEN** 回傳 HTTP 422 + +#### Scenario: 送審成功 +- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit` +- **THEN** 狀態轉為 `pending`,`rejection_reason` 清空 + +#### Scenario: 無證照不可送審 +- **WHEN** 教練無任何證照即送審 +- **THEN** 回傳 HTTP 422,狀態不變 + +#### Scenario: pending 期間證照鎖定 +- **WHEN** `pending` 或 `approved` 教練嘗試上傳或刪除證照 +- **THEN** 回傳 HTTP 422 + +#### Scenario: 查詢自身驗證狀態 +- **WHEN** 教練 `GET /api/provider/verification` +- **THEN** 回傳 `status`、`rejection_reason`、`certifications[]`(id、url) + +--- + +### Requirement: Admin 審核佇列與裁決 +Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject`,`reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點 SHALL 移除。 + +#### Scenario: 通過審核 +- **WHEN** Admin 對 pending 教練執行 approve +- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性 + +#### Scenario: 駁回需附原因 +- **WHEN** Admin 執行 reject 未帶 `reason` +- **THEN** 回傳 HTTP 422,狀態不變 + +#### Scenario: 撤銷已通過教練 +- **WHEN** Admin 對 approved 教練執行 reject 並附原因 +- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常(沿用既定政策) + +#### Scenario: 舊 toggle 端點已移除 +- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified` +- **THEN** 回傳 HTTP 404 + +--- + +### Requirement: 審核結果通知 +系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。 + +#### Scenario: 通過通知 +- **WHEN** Admin approve +- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息 + +#### Scenario: 駁回通知含原因 +- **WHEN** Admin reject 並附原因 +- **THEN** 教練收到的通知內容包含該原因 + +## MODIFIED Requirements + +### Requirement: 未驗證教練的課程不對公開端點曝光 +公開課程端點(列表、詳情、schedules、reviews)SHALL 僅曝光 `provider_id` 為 null 或教練 `verification_status = 'approved'` 的課程(原以 `is_verified = true` 判定,語意等價)。`POST /api/member/bookings` 的可預約檢查同步改以 `approved` 判定。其餘行為(404、422、既有預約不受影響)不變。 + +#### Scenario: 非 approved 狀態一律不曝光 +- **WHEN** 課程教練的狀態為 unsubmitted / pending / rejected +- **THEN** 公開端點不回傳該課程,新預約被拒 diff --git a/openspec/changes/provider-verification-workflow/tasks.md b/openspec/changes/provider-verification-workflow/tasks.md new file mode 100644 index 0000000..e4d67d2 --- /dev/null +++ b/openspec/changes/provider-verification-workflow/tasks.md @@ -0,0 +1,47 @@ +## 1. 資料模型 + +- [x] 1.1 新增 `App\Enums\VerificationStatus`(Unsubmitted / Pending / Approved / Rejected,含 `VALID_TRANSITIONS` 與 `canTransitionTo()`,仿 BookingStatus 模式) +- [x] 1.2 Migration:`provider_profiles` 加 `verification_status`(string, default 'unsubmitted')與 `rejection_reason`(text nullable);資料轉換 `is_verified=true→approved`、`false→unsubmitted`;drop `is_verified` +- [x] 1.3 Migration:新表 `provider_certifications`(id, user_id FK cascade, image_path, timestamps) +- [x] 1.4 `ProviderProfile`:fillable/casts 更新、`is_verified` accessor(appends,= approved)、`certifications` 由 User 關聯 +- [x] 1.5 新 Model `ProviderCertification`(belongsTo User、url accessor,仿 CourseImage) + +## 2. 教練端 API + +- [x] 2.1 `ProviderVerificationController`:`show`(status + reason + certifications)、`uploadCertification`(≤3 張、複用 CompressesImages、僅 unsubmitted/rejected)、`deleteCertification`(僅本人 + 僅 unsubmitted/rejected)、`submit`(≥1 張證照、unsubmitted/rejected→pending、清 rejection_reason) +- [x] 2.2 routes:`/api/provider/verification*` 四端點(auth:sanctum + provider group) + +## 3. Admin 端 API + +- [x] 3.1 `AdminVerificationController`:`index`(?status= 預設 pending,含教練資料與證照)、`approve`(pending→approved)、`reject`(pending|approved→rejected,reason 必填 max 500);approve/reject flush diving_offers 快取 + 通知教練 +- [x] 3.2 routes:`/api/admin/verifications*` 三端點;移除 toggle-verified route 與 `AdminUserController::toggleProviderVerified` +- [x] 3.3 `AdminUserController::providers` 列表回應含 `verification_status` + +## 4. 可見性判定切換 + +- [x] 4.1 `DivingOffer::visibleToPublic`:`is_verified=true` → `verification_status='approved'` +- [x] 4.2 `MemberBookingController::store` 可預約檢查同步改判 approved + +## 5. 通知 + +- [x] 5.1 `ProviderVerificationApprovedNotification` + `ProviderVerificationRejectedNotification`(含原因;database + mail,仿 Booking* 模式) + +## 6. Seeder + +- [x] 6.1 DemoSeeder / DevelopmentSeeder:`is_verified` 改 `verification_status`;demo 第 4 位教練給 1 張證照 + pending(展示佇列) + +## 7. 前端 + +- [x] 7.1 `views/coach/VerificationView.vue` + route `/coach/verification`:狀態卡(四態 + 駁回原因)、證照上傳/刪除、送審;CoachLayout 側欄入口 +- [x] 7.2 `views/admin/ProvidersView.vue`:badge 四態化、待審 filter、查看證照、通過/駁回(原因輸入);移除 toggle 呼叫 + +## 8. 測試 + +- [x] 8.1 既有測試遷移:DivingOfferVisibilityTest / BookingLifecycleTest / BookingOversellTest / ReviewTest 的 helper 改 `verification_status` +- [x] 8.2 `ProviderVerificationTest`(新):狀態機合法/非法轉移、證照上限/鎖定/本人限定、送審前置條件、重送清空原因 +- [x] 8.3 `AdminVerificationTest`(新):佇列過濾、approve/reject 權限(非 admin 403)、reject 無原因 422、撤銷 approved、裁決後可見性立即生效(快取)、通知發送斷言、toggle 端點 404 +- [x] 8.4 容器內全套件綠(基準 155 passed) + +## 9. 規格同步 + +- [x] 9.1 specs 增量套用至主規格 `provider-verification` 與 `admin-user-management` diff --git a/openspec/specs/admin-user-management/spec.md b/openspec/specs/admin-user-management/spec.md index 0809403..7b5931c 100644 --- a/openspec/specs/admin-user-management/spec.md +++ b/openspec/specs/admin-user-management/spec.md @@ -57,13 +57,13 @@ --- -### Requirement: 管理員驗證教練 -後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-verified`,反轉 ProviderProfile.is_verified 狀態。 +### Requirement: 管理員驗證教練(已由審核狀態機取代) +`PUT /api/admin/providers/{id}/toggle-verified` 已於 2026-06-12 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`(見 provider-verification 規格「Admin 審核佇列與裁決」)。 -#### Scenario: 驗證教練 -- **WHEN** 管理員對 is_verified=false 的教練發送請求 -- **THEN** 將 is_verified 設為 true,回傳 HTTP 200,`{ status: true, message: "教練已驗證", data: { is_verified: true } }` +#### Scenario: toggle 端點回 404 +- **WHEN** 管理員請求 `PUT /api/admin/providers/{id}/toggle-verified` +- **THEN** 回傳 HTTP 404 -#### Scenario: 取消驗證教練 -- **WHEN** 管理員對 is_verified=true 的教練發送請求 -- **THEN** 將 is_verified 設為 false,回傳 HTTP 200,`{ status: true, message: "已取消驗證", data: { is_verified: false } }` +#### Scenario: 教練列表仍顯示驗證狀態 +- **WHEN** 管理員請求 `GET /api/admin/providers` +- **THEN** 每筆教練的 provider_profile 包含 `verification_status` 與相容用 `is_verified`(accessor,= approved) diff --git a/openspec/specs/provider-verification/spec.md b/openspec/specs/provider-verification/spec.md index 982f37d..dda047d 100644 --- a/openspec/specs/provider-verification/spec.md +++ b/openspec/specs/provider-verification/spec.md @@ -2,77 +2,119 @@ ## Purpose -定義 `provider_profiles.is_verified` 的最小業務語意:未通過平台驗證的教練,其課程不對公開端點曝光。在完整教練審核流程(證照上傳、審核佇列、駁回原因)實作前,先以此約束堵住「未審核教練可公開曝光」的風險。 +定義教練資質審核的完整生命週期:教練上傳證照送審、Admin 審核裁決(通過/駁回含原因)、結果通知,以及「未通過審核(approved)之教練的課程不對公開端點曝光、不可接受新預約」的可見性約束。 ## ADDED Requirements -### Requirement: 未驗證教練的課程不對公開端點曝光 -公開課程端點(`GET /api/diving-offers`、`GET /api/diving-offers/{id}`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬 Provider 的 `provider_profiles.is_verified = true`。未驗證教練的課程在列表中 SHALL 被排除,在詳情端點 SHALL 回傳 404。 +### Requirement: 教練驗證狀態機 +`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending`、`approved`、`rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`(= status 為 approved)。 -#### Scenario: 已驗證教練的課程正常曝光 -- **WHEN** 匿名使用者請求 `GET /api/diving-offers` -- **THEN** 已驗證教練(is_verified=true)的課程出現在結果中 +#### Scenario: 新教練註冊預設未送審 +- **WHEN** 教練完成註冊 +- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列 -#### Scenario: 未驗證教練的課程從列表排除 -- **WHEN** 匿名使用者請求 `GET /api/diving-offers` -- **THEN** 未驗證教練(is_verified=false 或無 ProviderProfile)的課程不出現在結果中 +#### Scenario: 非法轉移被拒絕 +- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve +- **THEN** 回傳 HTTP 422,狀態不變 -#### Scenario: 未驗證教練的課程詳情回 404 -- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}`,該課程屬於未驗證教練 +--- + +### Requirement: 教練上傳證照與送審 +教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定(審核依據不可變動)。送審需至少 1 張證照。 + +#### Scenario: 上傳證照 +- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張 +- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊 + +#### Scenario: 超過 3 張上限 +- **WHEN** 教練已有 3 張證照再上傳 +- **THEN** 回傳 HTTP 422 + +#### Scenario: 送審成功 +- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit` +- **THEN** 狀態轉為 `pending`,`rejection_reason` 清空 + +#### Scenario: 無證照不可送審 +- **WHEN** 教練無任何證照即送審 +- **THEN** 回傳 HTTP 422,狀態不變 + +#### Scenario: pending 期間證照鎖定 +- **WHEN** `pending` 或 `approved` 教練嘗試上傳或刪除證照 +- **THEN** 回傳 HTTP 422 + +#### Scenario: 查詢自身驗證狀態 +- **WHEN** 教練 `GET /api/provider/verification` +- **THEN** 回傳 `status`、`rejection_reason`、`certifications[]`(id、url) + +--- + +### Requirement: Admin 審核佇列與裁決 +Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject`,`reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點已移除。 + +#### Scenario: 通過審核 +- **WHEN** Admin 對 pending 教練執行 approve +- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性(不受 180 秒快取影響) + +#### Scenario: 駁回需附原因 +- **WHEN** Admin 執行 reject 未帶 `reason` +- **THEN** 回傳 HTTP 422,狀態不變 + +#### Scenario: 撤銷已通過教練 +- **WHEN** Admin 對 approved 教練執行 reject 並附原因 +- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常 + +#### Scenario: 舊 toggle 端點已移除 +- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified` - **THEN** 回傳 HTTP 404 +--- + +### Requirement: 審核結果通知 +系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。 + +#### Scenario: 通過通知 +- **WHEN** Admin approve +- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息 + +#### Scenario: 駁回通知含原因 +- **WHEN** Admin reject 並附原因 +- **THEN** 教練收到的通知內容包含該原因 + +--- + +### Requirement: 未通過審核教練的課程不對公開端點曝光 +公開課程端點(`GET /api/diving-offers` 列表、`GET /api/diving-offers/{id}` 詳情、`/{id}/schedules`、`/{id}/reviews`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬教練 `verification_status = 'approved'`。其餘狀態(unsubmitted / pending / rejected,或無 ProviderProfile)的課程在列表中排除、詳情與子端點回傳 404。 + +#### Scenario: approved 教練的課程正常曝光 +- **WHEN** 匿名使用者請求公開課程端點 +- **THEN** approved 教練的課程出現在結果中,詳情/時段/評價端點照常回傳 + +#### Scenario: 非 approved 教練的課程不曝光 +- **WHEN** 課程教練狀態為 unsubmitted / pending / rejected +- **THEN** 列表排除該課程,詳情/時段/評價端點回傳 404 + #### Scenario: provider_id 為 null 的課程不受限 - **WHEN** 匿名使用者請求公開課程端點,課程的 `provider_id` 為 null - **THEN** 課程正常曝光 --- -### Requirement: 驗證狀態切換立即生效 -管理員透過 `PUT /api/admin/providers/{id}/toggle-verified` 切換驗證狀態後,公開課程列表的快取(`diving_offers` cache tag)SHALL 立即失效,下次請求反映最新可見性。 +### Requirement: 未通過審核教練的課程不可建立新預約 +`POST /api/member/bookings` SHALL 在建立預約前驗證 schedule 所屬課程可預約(`provider_id` 為 null 或教練 `verification_status = 'approved'`),不符時回傳 HTTP 422,不建立預約。既有預約(pending / confirmed / completed)SHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價。 -#### Scenario: 取消驗證後課程立即從公開列表消失 -- **WHEN** 管理員將教練 is_verified 由 true 切為 false -- **THEN** 下一次 `GET /api/diving-offers` 請求不包含該教練的課程(不受 180 秒快取影響) - ---- - -### Requirement: 公開子端點套用相同可見性 -課程的公開子端點(`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews`)SHALL 套用與課程詳情相同的可見性規則:課程屬未驗證教練時回傳 HTTP 404。 - -#### Scenario: 隱藏課程的時段查詢回 404 -- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}/schedules`,該課程屬未驗證教練 -- **THEN** 回傳 HTTP 404 - -#### Scenario: 隱藏課程的評價查詢回 404 -- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}/reviews`,該課程屬未驗證教練 -- **THEN** 回傳 HTTP 404 - -#### Scenario: 可見課程的子端點正常 -- **WHEN** 課程屬已驗證教練或 `provider_id` 為 null -- **THEN** 時段與評價端點照常回傳資料 - ---- - -### Requirement: 未驗證教練的課程不可建立新預約 -`POST /api/member/bookings` SHALL 在建立預約前驗證 schedule 所屬課程可預約(`provider_id` 為 null 或教練 `is_verified = true`),不符時回傳 HTTP 422,不建立預約。既有預約(pending / confirmed / completed)SHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價。 - -#### Scenario: 未驗證教練課程的新預約被拒絕 -- **WHEN** 會員以未驗證教練課程的 schedule_id 送出預約 +#### Scenario: 未通過審核教練課程的新預約被拒絕 +- **WHEN** 會員以非 approved 教練課程的 schedule_id 送出預約 - **THEN** 回傳 HTTP 422,`{ status: false, message: "此課程目前不開放預約" }`,不建立 Booking -#### Scenario: 教練被取消驗證後既有預約照常 -- **WHEN** 教練在預約 confirmed 之後被取消驗證 +#### Scenario: 教練被撤銷驗證後既有預約照常 +- **WHEN** 教練在預約 confirmed 之後被撤銷驗證(approved→rejected) - **THEN** 該預約的聊天、完課、評價流程照常運作;僅新預約被擋 --- ### Requirement: 教練自有管理端點不受可見性限制 -Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未驗證教練仍可登入、編輯與管理自己的課程。 +Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未通過審核的教練仍可登入、編輯與管理自己的課程、上傳證照與送審。 -#### Scenario: 未驗證教練仍可管理自己的課程 -- **WHEN** 未驗證教練以有效 token 請求 `GET /api/provider/offers` +#### Scenario: 未通過審核教練仍可管理自己的課程 +- **WHEN** 未通過審核教練以有效 token 請求 `GET /api/provider/offers` - **THEN** 回傳該教練的全部課程 - -## Notes - -完整審核流程(verification_status enum、證照上傳、審核佇列)見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1。 diff --git a/routes/api.php b/routes/api.php index 400ad5e..d1d5c62 100644 --- a/routes/api.php +++ b/routes/api.php @@ -84,6 +84,11 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { Route::get('/offers/{id}', [ProviderOfferController::class, 'show']); Route::put('/offers/{id}', [ProviderOfferController::class, 'update']); Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']); + // 驗證申請(證照送審) + Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']); + Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']); + Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']); + Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']); // 課程圖片 Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']); Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']); @@ -130,7 +135,10 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function () Route::get('/providers', [AdminUserController::class, 'providers']); Route::get('/providers/{id}', [AdminUserController::class, 'provider']); Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']); - Route::put('/providers/{id}/toggle-verified', [AdminUserController::class, 'toggleProviderVerified']); + // 教練審核(toggle-verified 已由審核狀態機取代) + Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']); + Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']); + Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']); // 課程管理 Route::get('/offers', [AdminOfferController::class, 'index']); Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 71ff3ec..288b178 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -612,28 +612,29 @@ ] } }, - "/admin/providers/{id}/toggle-verified": { - "put": { + "/admin/verifications": { + "get": { "tags": [ "Admin 教練管理" ], - "summary": "切換教練審核狀態", - "description": "通過或撤銷教練審核,回傳新的 is_verified 狀態", - "operationId": "toggleProviderVerified", + "summary": "教練審核佇列", + "description": "查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL", + "operationId": "adminVerificationIndex", "parameters": [ { - "name": "id", - "in": "path", - "description": "使用者 ID", - "required": true, + "name": "status", + "in": "query", + "description": "unsubmitted / pending / approved / rejected / all", + "required": false, "schema": { - "type": "integer" + "type": "string", + "default": "pending" } } ], "responses": { "200": { - "description": "切換成功", + "description": "查詢成功", "content": { "application/json": { "schema": { @@ -642,13 +643,54 @@ "type": "boolean", "example": true }, - "message": { - "type": "string", - "example": "教練已通過審核" - }, - "is_verified": { - "type": "boolean", - "example": true + "data": { + "type": "array", + "items": { + "properties": { + "user_id": { + "type": "integer", + "example": 5 + }, + "name": { + "type": "string", + "example": "王教練" + }, + "email": { + "type": "string", + "example": "coach@example.com" + }, + "business_name": { + "type": "string", + "example": "藍海潛水" + }, + "verification_status": { + "type": "string", + "example": "pending" + }, + "rejection_reason": { + "type": "string", + "example": null, + "nullable": true + }, + "certifications": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "url": { + "type": "string", + "example": "http://localhost:8080/storage/providers/5/certifications/uuid.jpg" + } + }, + "type": "object" + } + } + }, + "type": "object" + } } }, "type": "object" @@ -656,6 +698,47 @@ } } }, + "403": { + "description": "非 admin 角色", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/admin/verifications/{userId}/approve": { + "put": { + "tags": [ + "Admin 教練管理" + ], + "summary": "通過教練審核", + "description": "將 pending 教練轉為 approved,課程恢復公開曝光並通知教練", + "operationId": "adminVerificationApprove", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "教練使用者 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "已通過審核" + }, "403": { "description": "非 admin 角色", "content": { @@ -675,6 +758,97 @@ } } } + }, + "422": { + "description": "當前狀態無法通過審核", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/admin/verifications/{userId}/reject": { + "put": { + "tags": [ + "Admin 教練管理" + ], + "summary": "駁回教練審核", + "description": "駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練", + "operationId": "adminVerificationReject", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "教練使用者 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "maxLength": 500, + "example": "證照影像不清晰,請重新拍攝上傳" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "已駁回" + }, + "403": { + "description": "非 admin 角色", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "教練不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "422": { + "description": "原因未填或狀態不可駁回", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } } }, "security": [ @@ -3021,133 +3195,6 @@ ] } }, - "/admin/register": { - "post": { - "tags": [ - "管理員" - ], - "summary": "管理員註冊", - "description": "建立新的管理員帳號", - "operationId": "registerAdmin", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "required": [ - "name", - "email", - "password", - "password_confirmation" - ], - "properties": { - "name": { - "description": "使用者姓名", - "type": "string", - "example": "張管理" - }, - "email": { - "description": "電子郵件", - "type": "string", - "format": "email", - "example": "admin@example.com" - }, - "password": { - "description": "密碼", - "type": "string", - "format": "password", - "example": "password123" - }, - "password_confirmation": { - "description": "確認密碼", - "type": "string", - "format": "password", - "example": "password123" - }, - "phone": { - "description": "電話號碼", - "type": "string", - "example": "0912345678" - }, - "position": { - "description": "職位", - "type": "string", - "example": "系統管理員" - }, - "department": { - "description": "部門", - "type": "string", - "example": "IT部門" - } - }, - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "管理員註冊成功", - "content": { - "application/json": { - "schema": { - "properties": { - "status": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "管理員註冊成功" - }, - "data": { - "properties": { - "user": { - "$ref": "#/components/schemas/User" - }, - "token": { - "type": "string", - "example": "1|abcdef1234567890" - }, - "token_type": { - "type": "string", - "example": "Bearer" - } - }, - "type": "object" - } - }, - "type": "object" - } - } - } - }, - "422": { - "description": "驗證失敗", - "content": { - "application/json": { - "schema": { - "properties": { - "status": { - "type": "boolean", - "example": false - }, - "message": { - "type": "string", - "example": "驗證失敗" - }, - "errors": { - "type": "object" - } - }, - "type": "object" - } - } - } - } - } - } - }, "/admin/login": { "post": { "tags": [ @@ -6993,8 +7040,19 @@ "type": "string", "example": "週一至週五 09:00-18:00,週六日 08:00-19:00" }, + "verification_status": { + "description": "審核狀態:unsubmitted / pending / approved / rejected", + "type": "string", + "example": "approved" + }, + "rejection_reason": { + "description": "駁回原因(rejected 時有值)", + "type": "string", + "example": null, + "nullable": true + }, "is_verified": { - "description": "是否通過平台驗證", + "description": "是否通過平台驗證(相容欄位,= verification_status 為 approved)", "type": "boolean", "example": true }, diff --git a/tests/Feature/AdminVerificationTest.php b/tests/Feature/AdminVerificationTest.php new file mode 100644 index 0000000..a8d024c --- /dev/null +++ b/tests/Feature/AdminVerificationTest.php @@ -0,0 +1,151 @@ +create(['role' => 'admin']); + } + + private function makeProvider(string $status): User + { + $provider = User::factory()->create(['role' => 'provider']); + ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $status]); + + return $provider; + } + + // ── 佇列 ───────────────────────────────────────────────── + + public function test_queue_defaults_to_pending_only(): void + { + $pending = $this->makeProvider('pending'); + $this->makeProvider('unsubmitted'); + $this->makeProvider('approved'); + + $response = $this->actingAs($this->makeAdmin())->getJson('/api/admin/verifications'); + + $response->assertOk(); + $ids = array_column($response->json('data'), 'user_id'); + $this->assertSame([$pending->id], $ids); + } + + public function test_queue_can_filter_all_statuses(): void + { + $this->makeProvider('pending'); + $this->makeProvider('approved'); + + $response = $this->actingAs($this->makeAdmin())->getJson('/api/admin/verifications?status=all'); + + $this->assertCount(2, $response->json('data')); + } + + // ── 裁決狀態機 ─────────────────────────────────────────── + + public function test_approve_pending_provider_and_notify(): void + { + $provider = $this->makeProvider('pending'); + + $this->actingAs($this->makeAdmin()) + ->putJson("/api/admin/verifications/{$provider->id}/approve") + ->assertOk(); + + $this->assertSame('approved', $provider->providerProfile->fresh()->verification_status->value); + Notification::assertSentTo($provider, ProviderVerificationApprovedNotification::class); + } + + public function test_reject_requires_reason(): void + { + $provider = $this->makeProvider('pending'); + + $this->actingAs($this->makeAdmin()) + ->putJson("/api/admin/verifications/{$provider->id}/reject") + ->assertStatus(422); + + $this->assertSame('pending', $provider->providerProfile->fresh()->verification_status->value); + } + + public function test_reject_pending_provider_stores_reason_and_notifies(): void + { + $provider = $this->makeProvider('pending'); + + $this->actingAs($this->makeAdmin()) + ->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '證照已過期']) + ->assertOk(); + + $profile = $provider->providerProfile->fresh(); + $this->assertSame('rejected', $profile->verification_status->value); + $this->assertSame('證照已過期', $profile->rejection_reason); + Notification::assertSentTo($provider, ProviderVerificationRejectedNotification::class); + } + + public function test_cannot_approve_unsubmitted_provider(): void + { + $provider = $this->makeProvider('unsubmitted'); + + $this->actingAs($this->makeAdmin()) + ->putJson("/api/admin/verifications/{$provider->id}/approve") + ->assertStatus(422); + } + + public function test_can_revoke_approved_provider_with_reason(): void + { + $provider = $this->makeProvider('approved'); + + $this->actingAs($this->makeAdmin()) + ->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '收到檢舉,資料不實']) + ->assertOk(); + + $this->assertSame('rejected', $provider->providerProfile->fresh()->verification_status->value); + } + + // ── 權限邊界與舊端點 ───────────────────────────────────── + + public function test_non_admin_cannot_access_verification_endpoints(): void + { + $provider = $this->makeProvider('pending'); + $member = User::factory()->create(['role' => 'member']); + + $this->actingAs($member)->getJson('/api/admin/verifications')->assertStatus(403); + $this->actingAs($member) + ->putJson("/api/admin/verifications/{$provider->id}/approve") + ->assertStatus(403); + $this->actingAs($provider) + ->putJson("/api/admin/verifications/{$provider->id}/approve") + ->assertStatus(403); + } + + public function test_legacy_toggle_verified_endpoint_is_removed(): void + { + $provider = $this->makeProvider('approved'); + + $this->actingAs($this->makeAdmin()) + ->putJson("/api/admin/providers/{$provider->id}/toggle-verified") + ->assertStatus(404); + } +} diff --git a/tests/Feature/BookingLifecycleTest.php b/tests/Feature/BookingLifecycleTest.php index 014191c..c61e708 100644 --- a/tests/Feature/BookingLifecycleTest.php +++ b/tests/Feature/BookingLifecycleTest.php @@ -44,7 +44,7 @@ class BookingLifecycleTest extends TestCase ProviderProfile::create([ 'user_id' => $provider->id, - 'is_verified' => $isVerified, + 'verification_status' => $isVerified ? 'approved' : 'unsubmitted', ]); return $provider; @@ -171,8 +171,8 @@ class BookingLifecycleTest extends TestCase $member = $this->makeMember(); $booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed); - // 教練在預約成立後被取消驗證:只擋新預約,不毀既有合約 - $provider->providerProfile->update(['is_verified' => false]); + // 教練在預約成立後被撤銷驗證(approved→rejected):只擋新預約,不毀既有合約 + $provider->providerProfile->update(['verification_status' => 'rejected', 'rejection_reason' => '測試撤銷']); $this->actingAs($member) ->getJson("/api/bookings/{$booking->id}/messages") diff --git a/tests/Feature/BookingOversellTest.php b/tests/Feature/BookingOversellTest.php index 7ddc485..caf1c37 100644 --- a/tests/Feature/BookingOversellTest.php +++ b/tests/Feature/BookingOversellTest.php @@ -36,7 +36,7 @@ class BookingOversellTest extends TestCase ProviderProfile::create([ 'user_id' => $provider->id, - 'is_verified' => true, + 'verification_status' => 'approved', ]); $offer = DivingOffer::create([ diff --git a/tests/Feature/DivingOfferVisibilityTest.php b/tests/Feature/DivingOfferVisibilityTest.php index 84749f9..ce795ea 100644 --- a/tests/Feature/DivingOfferVisibilityTest.php +++ b/tests/Feature/DivingOfferVisibilityTest.php @@ -24,7 +24,7 @@ class DivingOfferVisibilityTest extends TestCase ProviderProfile::create([ 'user_id' => $provider->id, - 'is_verified' => $isVerified, + 'verification_status' => $isVerified ? 'approved' : 'unsubmitted', ]); return $provider; @@ -127,7 +127,7 @@ class DivingOfferVisibilityTest extends TestCase // ── 驗證狀態切換立即生效(快取失效) ───────────────────── - public function test_toggle_verified_takes_effect_immediately_despite_cache(): void + public function test_revoking_verification_takes_effect_immediately_despite_cache(): void { $provider = $this->makeProvider(true); $this->makeOffer($provider->id, 'Toggle Course'); @@ -139,10 +139,10 @@ class DivingOfferVisibilityTest extends TestCase array_column($this->getJson('/api/diving-offers')->json('data'), 'title') ); + // 撤銷驗證(approved→rejected)後快取必須立即失效 $this->actingAs($admin) - ->putJson("/api/admin/providers/{$provider->id}/toggle-verified") - ->assertOk() - ->assertJsonPath('data.is_verified', false); + ->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '資料不實']) + ->assertOk(); $this->assertNotContains( 'Toggle Course', diff --git a/tests/Feature/ProviderVerificationTest.php b/tests/Feature/ProviderVerificationTest.php new file mode 100644 index 0000000..f358adb --- /dev/null +++ b/tests/Feature/ProviderVerificationTest.php @@ -0,0 +1,163 @@ +create(['role' => 'provider']); + ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $status]); + + return $provider; + } + + private function addCertification(User $provider): ProviderCertification + { + return ProviderCertification::create([ + 'user_id' => $provider->id, + 'image_path' => "providers/{$provider->id}/certifications/test.jpg", + ]); + } + + // ── 查詢 ───────────────────────────────────────────────── + + public function test_provider_can_view_own_verification_state(): void + { + $provider = $this->makeProvider('rejected'); + $provider->providerProfile->update(['rejection_reason' => '證照不清晰']); + $this->addCertification($provider); + + $this->actingAs($provider)->getJson('/api/provider/verification') + ->assertOk() + ->assertJsonPath('data.verification_status', 'rejected') + ->assertJsonPath('data.rejection_reason', '證照不清晰') + ->assertJsonCount(1, 'data.certifications'); + } + + // ── 證照上傳與鎖定 ─────────────────────────────────────── + + public function test_unsubmitted_provider_can_upload_certification(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + + $this->actingAs($provider) + ->postJson('/api/provider/verification/certifications', [ + 'image' => UploadedFile::fake()->image('cert.jpg', 800, 600), + ])->assertStatus(201); + + $this->assertSame(1, $provider->providerCertifications()->count()); + } + + public function test_certification_limit_is_three(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + foreach (range(1, 3) as $i) { + $this->addCertification($provider); + } + + $this->actingAs($provider) + ->postJson('/api/provider/verification/certifications', [ + 'image' => UploadedFile::fake()->image('cert4.jpg', 800, 600), + ])->assertStatus(422); + } + + public function test_certifications_are_locked_while_pending(): void + { + Storage::fake('public'); + $provider = $this->makeProvider('pending'); + $cert = $this->addCertification($provider); + + $this->actingAs($provider) + ->postJson('/api/provider/verification/certifications', [ + 'image' => UploadedFile::fake()->image('late.jpg', 800, 600), + ])->assertStatus(422); + + $this->actingAs($provider) + ->deleteJson("/api/provider/verification/certifications/{$cert->id}") + ->assertStatus(422); + } + + public function test_provider_cannot_delete_others_certification(): void + { + $owner = $this->makeProvider(); + $cert = $this->addCertification($owner); + $intruder = $this->makeProvider(); + + $this->actingAs($intruder) + ->deleteJson("/api/provider/verification/certifications/{$cert->id}") + ->assertStatus(403); + } + + // ── 送審狀態機 ─────────────────────────────────────────── + + public function test_submit_requires_at_least_one_certification(): void + { + $provider = $this->makeProvider(); + + $this->actingAs($provider) + ->postJson('/api/provider/verification/submit') + ->assertStatus(422); + + $this->assertSame('unsubmitted', $provider->providerProfile->fresh()->verification_status->value); + } + + public function test_submit_moves_unsubmitted_to_pending(): void + { + $provider = $this->makeProvider(); + $this->addCertification($provider); + + $this->actingAs($provider) + ->postJson('/api/provider/verification/submit') + ->assertOk(); + + $this->assertSame('pending', $provider->providerProfile->fresh()->verification_status->value); + } + + public function test_rejected_provider_can_resubmit_and_reason_is_cleared(): void + { + $provider = $this->makeProvider('rejected'); + $provider->providerProfile->update(['rejection_reason' => '證照過期']); + $this->addCertification($provider); + + $this->actingAs($provider) + ->postJson('/api/provider/verification/submit') + ->assertOk(); + + $profile = $provider->providerProfile->fresh(); + $this->assertSame('pending', $profile->verification_status->value); + $this->assertNull($profile->rejection_reason); + } + + public function test_pending_or_approved_provider_cannot_submit_again(): void + { + foreach (['pending', 'approved'] as $status) { + $provider = $this->makeProvider($status); + $this->addCertification($provider); + + $this->actingAs($provider) + ->postJson('/api/provider/verification/submit') + ->assertStatus(422); + + $this->assertSame($status, $provider->providerProfile->fresh()->verification_status->value); + } + } +} diff --git a/tests/Feature/ReviewTest.php b/tests/Feature/ReviewTest.php index b77e5ed..f7781f7 100644 --- a/tests/Feature/ReviewTest.php +++ b/tests/Feature/ReviewTest.php @@ -34,7 +34,7 @@ class ReviewTest extends TestCase { $provider = User::factory()->create(['role' => 'provider']); // 公開評價端點僅對已驗證教練的課程開放(provider-verification 規格) - ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]); + ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']); return DivingOffer::create([ 'provider_id' => $provider->id, 'title' => '測試潛水課程',