Compare commits
125 Commits
ff2484a48f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d1fe3d4138 | |||
| 0bffa909e2 | |||
| 9222bf17fc | |||
| b218fa0964 | |||
| a7e8468613 | |||
| 60ff56a571 | |||
| 4afe7b5fd5 | |||
| c3572682ad | |||
| 13b6b4145c | |||
| d423cf5079 | |||
| 55b2bff1b8 | |||
| e08c6652a5 | |||
| 2d14c058d1 | |||
| be629feef9 | |||
| 998f34139a | |||
| 0080e2da84 | |||
| 4720dc53f9 | |||
| 95a93b2d72 | |||
| c0616b17df | |||
| 6a74f766db | |||
| d8340de322 | |||
| aebce80152 | |||
| 5fdd5b3329 | |||
| daf72711e8 | |||
| 2fbcd0c50d | |||
| 979ea4df75 | |||
| 6b1e31c4af | |||
| 64874e1c12 | |||
| 19705fc8d7 | |||
| 80f5cf179f | |||
| d99ffb0fd6 | |||
| afdca18c67 | |||
| 4383239bab | |||
| 49a4b89655 | |||
| 66ff30f863 | |||
| d6cc1cbca9 | |||
| a18f52b2e8 | |||
| e40507bd60 | |||
| 43720482c4 | |||
| 37140554d3 | |||
| a7d5f81890 | |||
| c53e9657b3 | |||
| 6414222a18 | |||
| 87a4f70e65 | |||
| 99651b0bac | |||
| 07618fb1b3 | |||
| 5825e74d2d | |||
| 3384210c5c | |||
| 90d0a57e73 | |||
| c226275fc2 | |||
| 99cf35c1ec | |||
| 520ec487ee | |||
| a97a7d096c | |||
| 0dffca31bb | |||
| 7ca91da138 | |||
| cec2078035 | |||
| d3a0d300c6 | |||
| 63b25f96ac | |||
| 3c38d085bd | |||
| 88a81aac41 | |||
| aeb8c975e2 | |||
| 51784ecc64 | |||
| ca5b843362 | |||
| 6f446f601f | |||
| 0dabc4ebb5 | |||
| ca1b83a2e3 | |||
| 855d9e1d4a | |||
| 981f753480 | |||
| 3b90edf843 | |||
| b06d8524db | |||
| 2a0e9255ae | |||
| b2e20767d8 | |||
| d9f0fe11c6 | |||
| a94c2462b8 | |||
| cb9c9a9385 | |||
| 5b13a014be | |||
| 8d1ea49abc | |||
| 9be50d493b | |||
| 0a6bfd9df8 | |||
| 127101adb3 | |||
| 0160d72467 | |||
| cfd0455597 | |||
| 8974a22584 | |||
| 9bde1ae344 | |||
| 3ad5972a5a | |||
| b770aab42a | |||
| 8d41f473e4 | |||
| 5463fd70b1 | |||
| 54dd4bb7a6 | |||
| 71b35533f4 | |||
| eff2dd273c | |||
| 4a5559a79e | |||
| 83e0e599a0 | |||
| 6fcf57a2d2 | |||
| 5ba598c78f | |||
| 3809ac7ed0 | |||
| 6b3a0816f5 | |||
| 1ba4ca893a | |||
| 6286399a3b | |||
| f80319109c | |||
| 4cf83375f3 | |||
| 91589a6f0e | |||
| ab24f210a6 | |||
| cc010b5c83 | |||
| c10d4ebfe6 | |||
| 153aee5f94 | |||
| f996a0053b | |||
| e7776fa930 | |||
| 972829419b | |||
| ecfb268d61 | |||
| d4821e527b | |||
| 75462c2619 | |||
| 12bd854dce | |||
| d6fdfb07f0 | |||
| 250c50fc74 | |||
| 856c342757 | |||
| 0814a3196f | |||
| 0db38ee30d | |||
| 8c54808cd8 | |||
| 3ddad32129 | |||
| 0dda2bb2a3 | |||
| b475a61d14 | |||
| a3462f8493 | |||
| 05ef8b9316 | |||
| 95bafef52d |
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Apply"
|
||||||
|
description: Implement tasks from an OpenSpec change (Experimental)
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, artifacts, experimental]
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement tasks from an OpenSpec change.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||||
|
|
||||||
|
2. **Check status to understand the schema**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||||
|
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||||
|
|
||||||
|
3. **Get apply instructions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspec instructions apply --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||||
|
- Progress (total, complete, remaining)
|
||||||
|
- Task list with status
|
||||||
|
- Dynamic instruction based on current state
|
||||||
|
|
||||||
|
**Handle states:**
|
||||||
|
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||||
|
- If `state: "all_done"`: congratulate, suggest archive
|
||||||
|
- Otherwise: proceed to implementation
|
||||||
|
|
||||||
|
4. **Read context files**
|
||||||
|
|
||||||
|
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||||
|
The files depend on the schema being used:
|
||||||
|
- **spec-driven**: proposal, specs, design, tasks
|
||||||
|
- Other schemas: follow the contextFiles from CLI output
|
||||||
|
|
||||||
|
5. **Show current progress**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Schema being used
|
||||||
|
- Progress: "N/M tasks complete"
|
||||||
|
- Remaining tasks overview
|
||||||
|
- Dynamic instruction from CLI
|
||||||
|
|
||||||
|
6. **Implement tasks (loop until done or blocked)**
|
||||||
|
|
||||||
|
For each pending task:
|
||||||
|
- Show which task is being worked on
|
||||||
|
- Make the code changes required
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||||
|
- Continue to next task
|
||||||
|
|
||||||
|
**Pause if:**
|
||||||
|
- Task is unclear → ask for clarification
|
||||||
|
- Implementation reveals a design issue → suggest updating artifacts
|
||||||
|
- Error or blocker encountered → report and wait for guidance
|
||||||
|
- User interrupts
|
||||||
|
|
||||||
|
7. **On completion or pause, show status**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Tasks completed this session
|
||||||
|
- Overall progress: "N/M tasks complete"
|
||||||
|
- If all done: suggest archive
|
||||||
|
- If paused: explain why and wait for guidance
|
||||||
|
|
||||||
|
**Output During Implementation**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementing: <change-name> (schema: <schema-name>)
|
||||||
|
|
||||||
|
Working on task 3/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
|
||||||
|
Working on task 4/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Completion**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 7/7 tasks complete ✓
|
||||||
|
|
||||||
|
### Completed This Session
|
||||||
|
- [x] Task 1
|
||||||
|
- [x] Task 2
|
||||||
|
...
|
||||||
|
|
||||||
|
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Pause (Issue Encountered)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Paused
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 4/7 tasks complete
|
||||||
|
|
||||||
|
### Issue Encountered
|
||||||
|
<description of the issue>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. <option 1>
|
||||||
|
2. <option 2>
|
||||||
|
3. Other approach
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Keep going through tasks until done or blocked
|
||||||
|
- Always read context files before starting (from the apply instructions output)
|
||||||
|
- If task is ambiguous, pause and ask before implementing
|
||||||
|
- If implementation reveals issues, pause and suggest artifact updates
|
||||||
|
- Keep code changes minimal and scoped to each task
|
||||||
|
- Update task checkbox immediately after completing each task
|
||||||
|
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||||
|
- Use contextFiles from CLI output, don't assume specific file names
|
||||||
|
|
||||||
|
**Fluid Workflow Integration**
|
||||||
|
|
||||||
|
This skill supports the "actions on a change" model:
|
||||||
|
|
||||||
|
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||||
|
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Archive"
|
||||||
|
description: Archive a completed change in the experimental workflow
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, archive, experimental]
|
||||||
|
---
|
||||||
|
|
||||||
|
Archive a completed change in the experimental workflow.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no change name provided, prompt for selection**
|
||||||
|
|
||||||
|
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||||
|
|
||||||
|
Show only active changes (not already archived).
|
||||||
|
Include the schema used for each change if available.
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||||
|
|
||||||
|
2. **Check artifact completion status**
|
||||||
|
|
||||||
|
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||||
|
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used
|
||||||
|
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||||
|
|
||||||
|
**If any artifacts are not `done`:**
|
||||||
|
- Display warning listing incomplete artifacts
|
||||||
|
- Prompt user for confirmation to continue
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
3. **Check task completion status**
|
||||||
|
|
||||||
|
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||||
|
|
||||||
|
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||||
|
|
||||||
|
**If incomplete tasks found:**
|
||||||
|
- Display warning showing count of incomplete tasks
|
||||||
|
- Prompt user for confirmation to continue
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
**If no tasks file exists:** Proceed without task-related warning.
|
||||||
|
|
||||||
|
4. **Assess delta spec sync state**
|
||||||
|
|
||||||
|
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||||
|
|
||||||
|
**If delta specs exist:**
|
||||||
|
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||||
|
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||||
|
- Show a combined summary before prompting
|
||||||
|
|
||||||
|
**Prompt options:**
|
||||||
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
Create the archive directory if it doesn't exist:
|
||||||
|
```bash
|
||||||
|
mkdir -p openspec/changes/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||||
|
|
||||||
|
**Check if target already exists:**
|
||||||
|
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||||
|
- If no: Move the change directory to archive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Display summary**
|
||||||
|
|
||||||
|
Show archive completion summary including:
|
||||||
|
- Change name
|
||||||
|
- Schema that was used
|
||||||
|
- Archive location
|
||||||
|
- Spec sync status (synced / sync skipped / no delta specs)
|
||||||
|
- Note about any warnings (incomplete artifacts/tasks)
|
||||||
|
|
||||||
|
**Output On Success**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** ✓ Synced to main specs
|
||||||
|
|
||||||
|
All artifacts complete. All tasks complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Success (No Delta Specs)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** No delta specs
|
||||||
|
|
||||||
|
All artifacts complete. All tasks complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Success With Warnings**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete (with warnings)
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** Sync skipped (user chose to skip)
|
||||||
|
|
||||||
|
**Warnings:**
|
||||||
|
- Archived with 2 incomplete artifacts
|
||||||
|
- Archived with 3 incomplete tasks
|
||||||
|
- Delta spec sync was skipped (user chose to skip)
|
||||||
|
|
||||||
|
Review the archive if this was not intentional.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Error (Archive Exists)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Failed
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
|
||||||
|
Target archive directory already exists.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. Rename the existing archive
|
||||||
|
2. Delete the existing archive if it's a duplicate
|
||||||
|
3. Wait until a different date to archive
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Always prompt for change selection if not provided
|
||||||
|
- Use artifact graph (openspec status --json) for completion checking
|
||||||
|
- Don't block archive on warnings - just inform and confirm
|
||||||
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
|
- Show clear summary of what happened
|
||||||
|
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||||
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Explore"
|
||||||
|
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, explore, experimental, thinking]
|
||||||
|
---
|
||||||
|
|
||||||
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
|
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||||
|
- A vague idea: "real-time collaboration"
|
||||||
|
- A specific problem: "the auth system is getting unwieldy"
|
||||||
|
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||||
|
- A comparison: "postgres vs sqlite for this"
|
||||||
|
- Nothing (just enter explore mode)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Stance
|
||||||
|
|
||||||
|
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||||
|
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||||
|
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||||
|
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||||
|
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||||
|
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Might Do
|
||||||
|
|
||||||
|
Depending on what the user brings, you might:
|
||||||
|
|
||||||
|
**Explore the problem space**
|
||||||
|
- Ask clarifying questions that emerge from what they said
|
||||||
|
- Challenge assumptions
|
||||||
|
- Reframe the problem
|
||||||
|
- Find analogies
|
||||||
|
|
||||||
|
**Investigate the codebase**
|
||||||
|
- Map existing architecture relevant to the discussion
|
||||||
|
- Find integration points
|
||||||
|
- Identify patterns already in use
|
||||||
|
- Surface hidden complexity
|
||||||
|
|
||||||
|
**Compare options**
|
||||||
|
- Brainstorm multiple approaches
|
||||||
|
- Build comparison tables
|
||||||
|
- Sketch tradeoffs
|
||||||
|
- Recommend a path (if asked)
|
||||||
|
|
||||||
|
**Visualize**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Use ASCII diagrams liberally │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ State │────────▶│ State │ │
|
||||||
|
│ │ A │ │ B │ │
|
||||||
|
│ └────────┘ └────────┘ │
|
||||||
|
│ │
|
||||||
|
│ System diagrams, state machines, │
|
||||||
|
│ data flows, architecture sketches, │
|
||||||
|
│ dependency graphs, comparison tables │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Surface risks and unknowns**
|
||||||
|
- Identify what could go wrong
|
||||||
|
- Find gaps in understanding
|
||||||
|
- Suggest spikes or investigations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenSpec Awareness
|
||||||
|
|
||||||
|
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||||
|
|
||||||
|
### Check for context
|
||||||
|
|
||||||
|
At the start, quickly check what exists:
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells you:
|
||||||
|
- If there are active changes
|
||||||
|
- Their names, schemas, and status
|
||||||
|
- What the user might be working on
|
||||||
|
|
||||||
|
If the user mentioned a specific change name, read its artifacts for context.
|
||||||
|
|
||||||
|
### When no change exists
|
||||||
|
|
||||||
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
|
### When a change exists
|
||||||
|
|
||||||
|
If the user mentions a change or you detect one is relevant:
|
||||||
|
|
||||||
|
1. **Read existing artifacts for context**
|
||||||
|
- `openspec/changes/<name>/proposal.md`
|
||||||
|
- `openspec/changes/<name>/design.md`
|
||||||
|
- `openspec/changes/<name>/tasks.md`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
2. **Reference them naturally in conversation**
|
||||||
|
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||||
|
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||||
|
|
||||||
|
3. **Offer to capture when decisions are made**
|
||||||
|
|
||||||
|
| Insight Type | Where to Capture |
|
||||||
|
|----------------------------|--------------------------------|
|
||||||
|
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||||
|
| Requirement changed | `specs/<capability>/spec.md` |
|
||||||
|
| Design decision made | `design.md` |
|
||||||
|
| Scope changed | `proposal.md` |
|
||||||
|
| New work identified | `tasks.md` |
|
||||||
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
|
Example offers:
|
||||||
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
- "This is a new requirement. Add it to specs?"
|
||||||
|
- "This changes scope. Update the proposal?"
|
||||||
|
|
||||||
|
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Don't Have To Do
|
||||||
|
|
||||||
|
- Follow a script
|
||||||
|
- Ask the same questions every time
|
||||||
|
- Produce a specific artifact
|
||||||
|
- Reach a conclusion
|
||||||
|
- Stay on topic if a tangent is valuable
|
||||||
|
- Be brief (this is thinking time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending Discovery
|
||||||
|
|
||||||
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|
||||||
|
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||||
|
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||||
|
- **Don't rush** - Discovery is thinking time, not task time
|
||||||
|
- **Don't force structure** - Let patterns emerge naturally
|
||||||
|
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||||
|
- **Do visualize** - A good diagram is worth many paragraphs
|
||||||
|
- **Do explore the codebase** - Ground discussions in reality
|
||||||
|
- **Do question assumptions** - Including the user's and your own
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Propose"
|
||||||
|
description: Propose a new change - create it and generate all artifacts in one step
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, artifacts, experimental]
|
||||||
|
---
|
||||||
|
|
||||||
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no input provided, ask what they want to build**
|
||||||
|
|
||||||
|
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||||
|
> "What change do you want to work on? Describe what you want to build or fix."
|
||||||
|
|
||||||
|
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||||
|
|
||||||
|
2. **Create the change directory**
|
||||||
|
```bash
|
||||||
|
openspec new change "<name>"
|
||||||
|
```
|
||||||
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
|
3. **Get the artifact build order**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to get:
|
||||||
|
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||||
|
- `artifacts`: list of all artifacts with their status and dependencies
|
||||||
|
|
||||||
|
4. **Create artifacts in sequence until apply-ready**
|
||||||
|
|
||||||
|
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||||
|
|
||||||
|
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||||
|
|
||||||
|
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||||
|
- Get instructions:
|
||||||
|
```bash
|
||||||
|
openspec instructions <artifact-id> --change "<name>" --json
|
||||||
|
```
|
||||||
|
- The instructions JSON includes:
|
||||||
|
- `context`: Project background (constraints for you - do NOT include in output)
|
||||||
|
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||||
|
- `template`: The structure to use for your output file
|
||||||
|
- `instruction`: Schema-specific guidance for this artifact type
|
||||||
|
- `outputPath`: Where to write the artifact
|
||||||
|
- `dependencies`: Completed artifacts to read for context
|
||||||
|
- Read any completed dependency files for context
|
||||||
|
- Create the artifact file using `template` as the structure
|
||||||
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
|
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||||
|
- Stop when all `applyRequires` artifacts are done
|
||||||
|
|
||||||
|
c. **If an artifact requires user input** (unclear context):
|
||||||
|
- Use **AskUserQuestion tool** to clarify
|
||||||
|
- Then continue with creation
|
||||||
|
|
||||||
|
5. **Show final status**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
After completing all artifacts, summarize:
|
||||||
|
- Change name and location
|
||||||
|
- List of artifacts created with brief descriptions
|
||||||
|
- What's ready: "All artifacts created! Ready for implementation."
|
||||||
|
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||||
|
|
||||||
|
**Artifact Creation Guidelines**
|
||||||
|
|
||||||
|
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||||
|
- The schema defines what each artifact should contain - follow it
|
||||||
|
- Read dependency artifacts for context before creating new ones
|
||||||
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
|
- Always read dependency artifacts before creating a new one
|
||||||
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: openspec-apply-change
|
||||||
|
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement tasks from an OpenSpec change.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||||
|
|
||||||
|
2. **Check status to understand the schema**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||||
|
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||||
|
|
||||||
|
3. **Get apply instructions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspec instructions apply --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||||
|
- Progress (total, complete, remaining)
|
||||||
|
- Task list with status
|
||||||
|
- Dynamic instruction based on current state
|
||||||
|
|
||||||
|
**Handle states:**
|
||||||
|
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||||
|
- If `state: "all_done"`: congratulate, suggest archive
|
||||||
|
- Otherwise: proceed to implementation
|
||||||
|
|
||||||
|
4. **Read context files**
|
||||||
|
|
||||||
|
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||||
|
The files depend on the schema being used:
|
||||||
|
- **spec-driven**: proposal, specs, design, tasks
|
||||||
|
- Other schemas: follow the contextFiles from CLI output
|
||||||
|
|
||||||
|
5. **Show current progress**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Schema being used
|
||||||
|
- Progress: "N/M tasks complete"
|
||||||
|
- Remaining tasks overview
|
||||||
|
- Dynamic instruction from CLI
|
||||||
|
|
||||||
|
6. **Implement tasks (loop until done or blocked)**
|
||||||
|
|
||||||
|
For each pending task:
|
||||||
|
- Show which task is being worked on
|
||||||
|
- Make the code changes required
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||||
|
- Continue to next task
|
||||||
|
|
||||||
|
**Pause if:**
|
||||||
|
- Task is unclear → ask for clarification
|
||||||
|
- Implementation reveals a design issue → suggest updating artifacts
|
||||||
|
- Error or blocker encountered → report and wait for guidance
|
||||||
|
- User interrupts
|
||||||
|
|
||||||
|
7. **On completion or pause, show status**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Tasks completed this session
|
||||||
|
- Overall progress: "N/M tasks complete"
|
||||||
|
- If all done: suggest archive
|
||||||
|
- If paused: explain why and wait for guidance
|
||||||
|
|
||||||
|
**Output During Implementation**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementing: <change-name> (schema: <schema-name>)
|
||||||
|
|
||||||
|
Working on task 3/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
|
||||||
|
Working on task 4/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Completion**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 7/7 tasks complete ✓
|
||||||
|
|
||||||
|
### Completed This Session
|
||||||
|
- [x] Task 1
|
||||||
|
- [x] Task 2
|
||||||
|
...
|
||||||
|
|
||||||
|
All tasks complete! Ready to archive this change.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Pause (Issue Encountered)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Paused
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 4/7 tasks complete
|
||||||
|
|
||||||
|
### Issue Encountered
|
||||||
|
<description of the issue>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. <option 1>
|
||||||
|
2. <option 2>
|
||||||
|
3. Other approach
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Keep going through tasks until done or blocked
|
||||||
|
- Always read context files before starting (from the apply instructions output)
|
||||||
|
- If task is ambiguous, pause and ask before implementing
|
||||||
|
- If implementation reveals issues, pause and suggest artifact updates
|
||||||
|
- Keep code changes minimal and scoped to each task
|
||||||
|
- Update task checkbox immediately after completing each task
|
||||||
|
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||||
|
- Use contextFiles from CLI output, don't assume specific file names
|
||||||
|
|
||||||
|
**Fluid Workflow Integration**
|
||||||
|
|
||||||
|
This skill supports the "actions on a change" model:
|
||||||
|
|
||||||
|
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||||
|
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: openspec-archive-change
|
||||||
|
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Archive a completed change in the experimental workflow.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no change name provided, prompt for selection**
|
||||||
|
|
||||||
|
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||||
|
|
||||||
|
Show only active changes (not already archived).
|
||||||
|
Include the schema used for each change if available.
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||||
|
|
||||||
|
2. **Check artifact completion status**
|
||||||
|
|
||||||
|
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||||
|
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used
|
||||||
|
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||||
|
|
||||||
|
**If any artifacts are not `done`:**
|
||||||
|
- Display warning listing incomplete artifacts
|
||||||
|
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
3. **Check task completion status**
|
||||||
|
|
||||||
|
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||||
|
|
||||||
|
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||||
|
|
||||||
|
**If incomplete tasks found:**
|
||||||
|
- Display warning showing count of incomplete tasks
|
||||||
|
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
**If no tasks file exists:** Proceed without task-related warning.
|
||||||
|
|
||||||
|
4. **Assess delta spec sync state**
|
||||||
|
|
||||||
|
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||||
|
|
||||||
|
**If delta specs exist:**
|
||||||
|
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||||
|
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||||
|
- Show a combined summary before prompting
|
||||||
|
|
||||||
|
**Prompt options:**
|
||||||
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
Create the archive directory if it doesn't exist:
|
||||||
|
```bash
|
||||||
|
mkdir -p openspec/changes/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||||
|
|
||||||
|
**Check if target already exists:**
|
||||||
|
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||||
|
- If no: Move the change directory to archive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Display summary**
|
||||||
|
|
||||||
|
Show archive completion summary including:
|
||||||
|
- Change name
|
||||||
|
- Schema that was used
|
||||||
|
- Archive location
|
||||||
|
- Whether specs were synced (if applicable)
|
||||||
|
- Note about any warnings (incomplete artifacts/tasks)
|
||||||
|
|
||||||
|
**Output On Success**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||||
|
|
||||||
|
All artifacts complete. All tasks complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Always prompt for change selection if not provided
|
||||||
|
- Use artifact graph (openspec status --json) for completion checking
|
||||||
|
- Don't block archive on warnings - just inform and confirm
|
||||||
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
|
- Show clear summary of what happened
|
||||||
|
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||||
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
name: openspec-explore
|
||||||
|
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Stance
|
||||||
|
|
||||||
|
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||||
|
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||||
|
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||||
|
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||||
|
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||||
|
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Might Do
|
||||||
|
|
||||||
|
Depending on what the user brings, you might:
|
||||||
|
|
||||||
|
**Explore the problem space**
|
||||||
|
- Ask clarifying questions that emerge from what they said
|
||||||
|
- Challenge assumptions
|
||||||
|
- Reframe the problem
|
||||||
|
- Find analogies
|
||||||
|
|
||||||
|
**Investigate the codebase**
|
||||||
|
- Map existing architecture relevant to the discussion
|
||||||
|
- Find integration points
|
||||||
|
- Identify patterns already in use
|
||||||
|
- Surface hidden complexity
|
||||||
|
|
||||||
|
**Compare options**
|
||||||
|
- Brainstorm multiple approaches
|
||||||
|
- Build comparison tables
|
||||||
|
- Sketch tradeoffs
|
||||||
|
- Recommend a path (if asked)
|
||||||
|
|
||||||
|
**Visualize**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Use ASCII diagrams liberally │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ State │────────▶│ State │ │
|
||||||
|
│ │ A │ │ B │ │
|
||||||
|
│ └────────┘ └────────┘ │
|
||||||
|
│ │
|
||||||
|
│ System diagrams, state machines, │
|
||||||
|
│ data flows, architecture sketches, │
|
||||||
|
│ dependency graphs, comparison tables │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Surface risks and unknowns**
|
||||||
|
- Identify what could go wrong
|
||||||
|
- Find gaps in understanding
|
||||||
|
- Suggest spikes or investigations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenSpec Awareness
|
||||||
|
|
||||||
|
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||||
|
|
||||||
|
### Check for context
|
||||||
|
|
||||||
|
At the start, quickly check what exists:
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells you:
|
||||||
|
- If there are active changes
|
||||||
|
- Their names, schemas, and status
|
||||||
|
- What the user might be working on
|
||||||
|
|
||||||
|
### When no change exists
|
||||||
|
|
||||||
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
|
### When a change exists
|
||||||
|
|
||||||
|
If the user mentions a change or you detect one is relevant:
|
||||||
|
|
||||||
|
1. **Read existing artifacts for context**
|
||||||
|
- `openspec/changes/<name>/proposal.md`
|
||||||
|
- `openspec/changes/<name>/design.md`
|
||||||
|
- `openspec/changes/<name>/tasks.md`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
2. **Reference them naturally in conversation**
|
||||||
|
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||||
|
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||||
|
|
||||||
|
3. **Offer to capture when decisions are made**
|
||||||
|
|
||||||
|
| Insight Type | Where to Capture |
|
||||||
|
|----------------------------|--------------------------------|
|
||||||
|
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||||
|
| Requirement changed | `specs/<capability>/spec.md` |
|
||||||
|
| Design decision made | `design.md` |
|
||||||
|
| Scope changed | `proposal.md` |
|
||||||
|
| New work identified | `tasks.md` |
|
||||||
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
|
Example offers:
|
||||||
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
- "This is a new requirement. Add it to specs?"
|
||||||
|
- "This changes scope. Update the proposal?"
|
||||||
|
|
||||||
|
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Don't Have To Do
|
||||||
|
|
||||||
|
- Follow a script
|
||||||
|
- Ask the same questions every time
|
||||||
|
- Produce a specific artifact
|
||||||
|
- Reach a conclusion
|
||||||
|
- Stay on topic if a tangent is valuable
|
||||||
|
- Be brief (this is thinking time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handling Different Entry Points
|
||||||
|
|
||||||
|
**User brings a vague idea:**
|
||||||
|
```
|
||||||
|
User: I'm thinking about adding real-time collaboration
|
||||||
|
|
||||||
|
You: Real-time collab is a big space. Let me think about this...
|
||||||
|
|
||||||
|
COLLABORATION SPECTRUM
|
||||||
|
════════════════════════════════════════════
|
||||||
|
|
||||||
|
Awareness Coordination Sync
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────┐ ┌────────┐ ┌────────┐
|
||||||
|
│Presence│ │Cursors │ │ CRDT │
|
||||||
|
│ "3 │ │ Multi │ │Conflict│
|
||||||
|
│online" │ │ select │ │ free │
|
||||||
|
└────────┘ └────────┘ └────────┘
|
||||||
|
│ │ │
|
||||||
|
trivial moderate complex
|
||||||
|
|
||||||
|
Where's your head at?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User brings a specific problem:**
|
||||||
|
```
|
||||||
|
User: The auth system is a mess
|
||||||
|
|
||||||
|
You: [reads codebase]
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ CURRENT AUTH FLOW │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────┼─────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ Google │ │ GitHub │ │ Email │
|
||||||
|
│ OAuth │ │ OAuth │ │ Magic │
|
||||||
|
└────┬────┘ └────┬────┘ └────┬────┘
|
||||||
|
│ │ │
|
||||||
|
└─────────────┼─────────────┘
|
||||||
|
▼
|
||||||
|
┌───────────┐
|
||||||
|
│ Session │
|
||||||
|
└─────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────┐
|
||||||
|
│ Perms │
|
||||||
|
└───────────┘
|
||||||
|
|
||||||
|
I see three tangles. Which one's burning?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User is stuck mid-implementation:**
|
||||||
|
```
|
||||||
|
User: /opsx:explore add-auth-system
|
||||||
|
The OAuth integration is more complex than expected
|
||||||
|
|
||||||
|
You: [reads change artifacts]
|
||||||
|
|
||||||
|
You're on task 4: "Implement OAuth flow"
|
||||||
|
|
||||||
|
Let me trace what's involved...
|
||||||
|
|
||||||
|
[draws diagram, explores options, suggests paths]
|
||||||
|
|
||||||
|
Want to update the design to reflect this?
|
||||||
|
Or add a spike task to investigate?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User wants to compare options:**
|
||||||
|
```
|
||||||
|
User: Should we use Postgres or SQLite?
|
||||||
|
|
||||||
|
You: Generic answer is boring. What's the context?
|
||||||
|
|
||||||
|
User: A CLI tool that tracks local dev environments
|
||||||
|
|
||||||
|
You: That changes everything.
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ CLI TOOL DATA STORAGE │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Key constraints:
|
||||||
|
• No daemon running
|
||||||
|
• Must work offline
|
||||||
|
• Single user
|
||||||
|
|
||||||
|
SQLite Postgres
|
||||||
|
Deployment embedded ✓ needs server ✗
|
||||||
|
Offline yes ✓ no ✗
|
||||||
|
Single file yes ✓ no ✗
|
||||||
|
|
||||||
|
SQLite. Not even close.
|
||||||
|
|
||||||
|
Unless... is there a sync component?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending Discovery
|
||||||
|
|
||||||
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|
||||||
|
When it feels like things are crystallizing, you might summarize:
|
||||||
|
|
||||||
|
```
|
||||||
|
## What We Figured Out
|
||||||
|
|
||||||
|
**The problem**: [crystallized understanding]
|
||||||
|
|
||||||
|
**The approach**: [if one emerged]
|
||||||
|
|
||||||
|
**Open questions**: [if any remain]
|
||||||
|
|
||||||
|
**Next steps** (if ready):
|
||||||
|
- Create a change proposal
|
||||||
|
- Keep exploring: just keep talking
|
||||||
|
```
|
||||||
|
|
||||||
|
But this summary is optional. Sometimes the thinking IS the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||||
|
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||||
|
- **Don't rush** - Discovery is thinking time, not task time
|
||||||
|
- **Don't force structure** - Let patterns emerge naturally
|
||||||
|
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||||
|
- **Do visualize** - A good diagram is worth many paragraphs
|
||||||
|
- **Do explore the codebase** - Ground discussions in reality
|
||||||
|
- **Do question assumptions** - Including the user's and your own
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: openspec-propose
|
||||||
|
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no clear input provided, ask what they want to build**
|
||||||
|
|
||||||
|
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||||
|
> "What change do you want to work on? Describe what you want to build or fix."
|
||||||
|
|
||||||
|
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||||
|
|
||||||
|
2. **Create the change directory**
|
||||||
|
```bash
|
||||||
|
openspec new change "<name>"
|
||||||
|
```
|
||||||
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
|
3. **Get the artifact build order**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to get:
|
||||||
|
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||||
|
- `artifacts`: list of all artifacts with their status and dependencies
|
||||||
|
|
||||||
|
4. **Create artifacts in sequence until apply-ready**
|
||||||
|
|
||||||
|
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||||
|
|
||||||
|
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||||
|
|
||||||
|
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||||
|
- Get instructions:
|
||||||
|
```bash
|
||||||
|
openspec instructions <artifact-id> --change "<name>" --json
|
||||||
|
```
|
||||||
|
- The instructions JSON includes:
|
||||||
|
- `context`: Project background (constraints for you - do NOT include in output)
|
||||||
|
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||||
|
- `template`: The structure to use for your output file
|
||||||
|
- `instruction`: Schema-specific guidance for this artifact type
|
||||||
|
- `outputPath`: Where to write the artifact
|
||||||
|
- `dependencies`: Completed artifacts to read for context
|
||||||
|
- Read any completed dependency files for context
|
||||||
|
- Create the artifact file using `template` as the structure
|
||||||
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
|
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||||
|
- Stop when all `applyRequires` artifacts are done
|
||||||
|
|
||||||
|
c. **If an artifact requires user input** (unclear context):
|
||||||
|
- Use **AskUserQuestion tool** to clarify
|
||||||
|
- Then continue with creation
|
||||||
|
|
||||||
|
5. **Show final status**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
After completing all artifacts, summarize:
|
||||||
|
- Change name and location
|
||||||
|
- List of artifacts created with brief descriptions
|
||||||
|
- What's ready: "All artifacts created! Ready for implementation."
|
||||||
|
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||||
|
|
||||||
|
**Artifact Creation Guidelines**
|
||||||
|
|
||||||
|
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||||
|
- The schema defines what each artifact should contain - follow it
|
||||||
|
- Read dependency artifacts for context before creating new ones
|
||||||
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
|
- Always read dependency artifacts before creating a new one
|
||||||
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
+25
-5
@@ -15,8 +15,7 @@ APP_MAINTENANCE_STORE=database
|
|||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stderr
|
||||||
LOG_STACK=single
|
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
@@ -28,15 +27,15 @@ DB_CONNECTION=sqlite
|
|||||||
# DB_PASSWORD=
|
# DB_PASSWORD=
|
||||||
# MYSQL_ROOT_PASSWORD=
|
# MYSQL_ROOT_PASSWORD=
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=redis
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案)
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
|
||||||
|
|
||||||
CACHE_STORE=redis
|
CACHE_STORE=redis
|
||||||
CACHE_PREFIX=
|
CACHE_PREFIX=
|
||||||
@@ -64,3 +63,24 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
SENTRY_LARAVEL_DSN=
|
||||||
|
SENTRY_ENVIRONMENT="${APP_ENV}"
|
||||||
|
|
||||||
|
REVERB_APP_ID=
|
||||||
|
REVERB_APP_KEY=
|
||||||
|
REVERB_APP_SECRET=
|
||||||
|
REVERB_HOST=localhost
|
||||||
|
REVERB_PORT=8085
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
|
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||||
|
|
||||||
|
# Swagger UI — API 伺服器 Base URL(結尾不加斜線)
|
||||||
|
# 本地:http://localhost:8080/api
|
||||||
|
# 生產:https://api.hank-space.com/api
|
||||||
|
L5_SWAGGER_CONST_HOST=http://localhost:8080/api
|
||||||
|
L5_SWAGGER_GENERATE_ALWAYS=false
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Pull latest code
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
flock /tmp/cfdive-deploy.lock git fetch origin
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app php artisan migrate --force
|
||||||
|
|
||||||
|
- name: Cache configs
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app php artisan config:cache
|
||||||
|
docker compose exec -T app php artisan route:cache
|
||||||
|
docker compose exec -T app php artisan view:cache
|
||||||
|
docker compose exec -T app php artisan event:cache
|
||||||
|
|
||||||
|
- name: Generate Swagger docs
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app php artisan l5-swagger:generate
|
||||||
|
|
||||||
|
- name: Restart queue worker
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose restart queue-worker
|
||||||
|
|
||||||
|
- name: Rebuild frontend (if changed)
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
if git diff --name-only HEAD~1 HEAD | grep -q '^frontend/'; then
|
||||||
|
docker compose build frontend
|
||||||
|
docker compose up -d frontend
|
||||||
|
fi
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Run Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
apk add --no-cache \
|
||||||
|
php84 php84-phar php84-mbstring php84-dom php84-fileinfo php84-gd \
|
||||||
|
php84-pdo php84-pdo_sqlite php84-sqlite3 php84-tokenizer \
|
||||||
|
php84-xml php84-xmlwriter php84-ctype php84-curl php84-openssl \
|
||||||
|
php84-json php84-session php84-iconv \
|
||||||
|
curl unzip
|
||||||
|
ln -sf /usr/bin/php84 /usr/local/bin/php
|
||||||
|
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
||||||
|
|
||||||
|
- name: Copy .env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --prefer-dist --no-interaction --no-progress
|
||||||
|
|
||||||
|
- name: Generate application key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: php artisan test
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name: issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
help-wanted:
|
|
||||||
uses: laravel/.github/.github/workflows/issues.yml@main
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name: pull requests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
uneditable:
|
|
||||||
uses: laravel/.github/.github/workflows/pull-requests.yml@main
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- '*.x'
|
|
||||||
pull_request:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * *'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
php: [8.2, 8.3]
|
|
||||||
|
|
||||||
name: PHP ${{ matrix.php }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php }}
|
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
|
|
||||||
- name: Install Composer dependencies
|
|
||||||
run: composer install --prefer-dist --no-interaction --no-progress
|
|
||||||
|
|
||||||
- name: Copy environment file
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Generate app key
|
|
||||||
run: php artisan key:generate
|
|
||||||
|
|
||||||
- name: Execute tests
|
|
||||||
run: vendor/bin/phpunit
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
name: update changelog
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [released]
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
uses: laravel/.github/.github/workflows/update-changelog.yml@main
|
|
||||||
+3
-1
@@ -18,7 +18,8 @@ yarn-error.log
|
|||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
/.claude
|
/.claude/settings.local.json
|
||||||
|
/.claude/worktrees/
|
||||||
/.opensp
|
/.opensp
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
@@ -26,3 +27,4 @@ yarn-error.log
|
|||||||
/frontend/dist
|
/frontend/dist
|
||||||
/frontend/.env
|
/frontend/.env
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
|
## Before writing any code
|
||||||
|
|
||||||
|
Stop at the first rung that holds:
|
||||||
|
|
||||||
|
- Does this need to be built at all? (YAGNI)
|
||||||
|
- Does the standard library already do this? Use it.
|
||||||
|
- Does a native platform feature cover it? Use it.
|
||||||
|
- Does an already-installed dependency solve it? Use it.
|
||||||
|
- Can this be one line? Make it one line.
|
||||||
|
- Only then: write the minimum code that works.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- No abstractions that weren't explicitly requested.
|
||||||
|
- No new dependency if it can be avoided.
|
||||||
|
- No boilerplate nobody asked for.
|
||||||
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
|
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
|
||||||
|
- Mark intentional simplifications with a ponytail: comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
|
||||||
|
|
||||||
|
Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
|
||||||
+6
-9
@@ -7,6 +7,9 @@ RUN apt-get update && apt-get install -y \
|
|||||||
git \
|
git \
|
||||||
curl \
|
curl \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libwebp-dev \
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
zip \
|
zip \
|
||||||
@@ -14,14 +17,14 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libzip-dev \
|
libzip-dev \
|
||||||
default-mysql-client \
|
default-mysql-client \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
grep \
|
grep
|
||||||
cron
|
|
||||||
|
|
||||||
# 清理 apt 快取以減小鏡像大小
|
# 清理 apt 快取以減小鏡像大小
|
||||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 安裝 PHP 擴展
|
# 安裝 PHP 擴展
|
||||||
# 這些擴展是 Laravel 和一般 PHP 開發所需的
|
# GD 需要在 install 前先 configure,才能帶入 jpeg/webp/freetype 支援
|
||||||
|
RUN docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype
|
||||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||||
|
|
||||||
# 從官方 Composer 鏡像複製 Composer 執行文件
|
# 從官方 Composer 鏡像複製 Composer 執行文件
|
||||||
@@ -49,12 +52,6 @@ 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
|
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /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 指令之前執行
|
# 這將在 CMD 指令之前執行
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,4 +1,97 @@
|
|||||||

|
# CFDive Platform
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
|
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、即時訊息、評價與通知等完整服務。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能概覽
|
||||||
|
|
||||||
|
**會員(Member)**
|
||||||
|
- 註冊 / 登入(Email + Google OAuth)
|
||||||
|
- Token 自動續期(401 refresh-then-retry,sessionStorage 儲存)
|
||||||
|
- 瀏覽、搜尋、篩選潛水課程
|
||||||
|
- 查看課程時段並送出預約
|
||||||
|
- 與教練即時訊息(文字 + 圖片,含已讀回執)
|
||||||
|
- 對完成的課程留下評價(支援匿名、有幫助投票)
|
||||||
|
- 站內通知(Bell Icon 即時更新 + 瀏覽器推播)
|
||||||
|
|
||||||
|
**教練(Provider)**
|
||||||
|
- 課程 CRUD(含封面 + 相簿圖片上傳,伺服器端壓縮)
|
||||||
|
- 課程時段管理
|
||||||
|
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||||
|
- 與學員即時訊息
|
||||||
|
- 證照上傳與教練資格送審
|
||||||
|
|
||||||
|
**管理員(Admin)**
|
||||||
|
- 平台統計數據(會員數、教練數、課程數)
|
||||||
|
- 會員與教練帳號管理(啟用 / 停用)
|
||||||
|
- 教練資格審核(送審 / 通過 / 駁回 / 撤銷)
|
||||||
|
- 課程、預約、評價管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術棧
|
||||||
|
|
||||||
|
| 層級 | 技術 |
|
||||||
|
|------|------|
|
||||||
|
| 後端 | PHP 8.x / Laravel 11 |
|
||||||
|
| 前端 | Vue 3 + Vite + Tailwind CSS |
|
||||||
|
| 資料庫 | MySQL 8.0 |
|
||||||
|
| 快取 | Redis |
|
||||||
|
| 即時通訊 | Laravel Reverb(WebSocket,`wss://ws.hank-space.com`)|
|
||||||
|
| 認證 | Laravel Sanctum + Google OAuth + Token Refresh |
|
||||||
|
| 容器 | Docker / Docker Compose |
|
||||||
|
| API 文件 | Swagger UI(l5-swagger)|
|
||||||
|
| 錯誤監控 | Sentry |
|
||||||
|
| CI/CD | Gitea Actions(自動部署至 VPS)|
|
||||||
|
| 測試 | PHPUnit / Laravel Feature & Unit Tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 文件
|
||||||
|
|
||||||
|
Swagger UI(由 l5-swagger 生成)涵蓋所有端點,包含:
|
||||||
|
- 認證(Email + Google OAuth)、Token 刷新
|
||||||
|
- 公開課程查詢
|
||||||
|
- 會員預約 / 即時訊息 / 評價 / 通知
|
||||||
|
- 教練課程 / 時段 / 預約管理 / 資格驗證申請(證照送審)
|
||||||
|
- 管理員後台
|
||||||
|
|
||||||
|
| 環境 | Swagger UI |
|
||||||
|
|------|-----------|
|
||||||
|
| 本地 | http://localhost:8080/api/documentation |
|
||||||
|
| 生產 | https://api.hank-space.com/api/documentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 健康檢查
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
回傳 DB、Redis、Cache 狀態,供 UptimeRobot 監控使用。全部正常回 200,任一異常回 503。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地開發
|
||||||
|
|
||||||
|
**測試用帳號**
|
||||||
|
|
||||||
|
| 角色 | 帳號 | 密碼 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 會員 | Guest@cfdive.com | guestpassword | 體驗課程瀏覽、預約、會員預約紀錄 |
|
||||||
|
| 教練 | Guest_Coach@cfdive.com | coachpassword | 體驗課程管理、時段管理、預約管理 |
|
||||||
|
|
||||||
|
**教練頁入口**
|
||||||
|
|
||||||
|
目前首頁尚未提供明顯導引到教練後台的入口。若要體驗教練功能,請直接開啟 `/coach/login`,使用上方教練試用帳號登入後會進入 `/coach/dashboard`。
|
||||||
|
|
||||||
|
| 服務 | 本地 | 生產 |
|
||||||
|
|------|------|------|
|
||||||
|
| 前端 | http://localhost:8080 | https://app.hank-space.com |
|
||||||
|
| API | http://localhost:8080/api | https://api.hank-space.com/api |
|
||||||
|
| Swagger UI | http://localhost:8080/api/documentation | https://api.hank-space.com/api/documentation |
|
||||||
|
| phpMyAdmin | http://localhost:8081 | http://\<vps-ip\>:8081 |
|
||||||
|
| Mailpit | http://localhost:8025 | http://\<vps-ip\>:8025 |
|
||||||
|
| Reverb WebSocket | ws://localhost:8085 | wss://ws.hank-space.com |
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Broadcasting;
|
||||||
|
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class BookingPresenceChannel
|
||||||
|
{
|
||||||
|
public function join(User $user, Booking $booking): array|false
|
||||||
|
{
|
||||||
|
if ($booking->status->value !== 'confirmed') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$booking->loadMissing('schedule');
|
||||||
|
|
||||||
|
$isMember = $user->role === 'member' && $booking->member_id === $user->id;
|
||||||
|
$isProvider = $user->role === 'provider'
|
||||||
|
&& $booking->schedule !== null
|
||||||
|
&& $booking->schedule->provider_id === $user->id;
|
||||||
|
|
||||||
|
if (!$isMember && !$isProvider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_type' => $user->role,
|
||||||
|
'name' => $user->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AdminProfile;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class CreateAdminUser extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'app:create-admin
|
||||||
|
{name : 管理員姓名}
|
||||||
|
{email : 登入用電子郵件}
|
||||||
|
{--password= : 密碼(至少 8 碼,未提供時互動式輸入)}
|
||||||
|
{--position= : 職位}
|
||||||
|
{--department= : 部門}';
|
||||||
|
|
||||||
|
protected $description = '建立管理員帳號(公開 /api/admin/register 端點已移除,管理員一律由主機端建立)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$password = $this->option('password') ?: $this->secret('請輸入密碼(至少 8 碼)');
|
||||||
|
|
||||||
|
$validator = Validator::make([
|
||||||
|
'name' => $this->argument('name'),
|
||||||
|
'email' => $this->argument('email'),
|
||||||
|
'password' => $password,
|
||||||
|
], [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|string|email|max:255|unique:users',
|
||||||
|
// 管理權限影響全平台,密碼門檻高於一般使用者的 min:6
|
||||||
|
'password' => 'required|string|min:8',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
foreach ($validator->errors()->all() as $error) {
|
||||||
|
$this->error($error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $this->argument('name'),
|
||||||
|
'email' => $this->argument('email'),
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
'role' => 'admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AdminProfile::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'position' => $this->option('position'),
|
||||||
|
'department' => $this->option('department'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info("管理員帳號已建立:{$user->email}(id={$user->id})");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 統計",
|
||||||
|
* description="管理員平台統計"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 會員管理",
|
||||||
|
* description="管理員的會員帳號管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 教練管理",
|
||||||
|
* description="管理員的服務提供者帳號管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 課程管理",
|
||||||
|
* description="管理員的課程、預約、評價管理"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class AdminApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Stats
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得平台統計數據
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/stats",
|
||||||
|
* summary="取得平台統計數據",
|
||||||
|
* description="回傳會員總數、服務提供者總數、課程總數;非 admin 角色回傳 403",
|
||||||
|
* operationId="getAdminStats",
|
||||||
|
* tags={"Admin 統計"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="total_members", type="integer", example=128),
|
||||||
|
* @OA\Property(property="total_providers", type="integer", example=34),
|
||||||
|
* @OA\Property(property="total_offers", type="integer", example=87)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getAdminStats()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Member Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得會員列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/members",
|
||||||
|
* summary="取得會員列表",
|
||||||
|
* description="分頁回傳所有會員帳號,含 member_profile",
|
||||||
|
* operationId="listAdminMembers",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="name", type="string", example="王小明"),
|
||||||
|
* @OA\Property(property="email", type="string", example="member@example.com"),
|
||||||
|
* @OA\Property(property="role", type="string", example="member"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||||
|
* @OA\Property(property="member_profile", type="object", nullable=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminMembers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一會員
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/members/{id}",
|
||||||
|
* summary="取得單一會員",
|
||||||
|
* operationId="getAdminMember",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="object")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getAdminMember()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切換會員啟用狀態
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/members/{id}/toggle-active",
|
||||||
|
* summary="切換會員啟用狀態",
|
||||||
|
* description="啟用或停用指定會員帳號",
|
||||||
|
* operationId="toggleMemberActive",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="切換成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="帳號已停用"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function toggleMemberActive()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認會員存在
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/check-member/{id}",
|
||||||
|
* summary="確認會員存在",
|
||||||
|
* description="快速確認指定 ID 的會員是否存在(角色為 member)",
|
||||||
|
* operationId="checkMember",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="會員存在",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="exists", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function checkMember()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Provider Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得教練列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/providers",
|
||||||
|
* summary="取得教練列表",
|
||||||
|
* description="分頁回傳所有服務提供者,含 provider_profile",
|
||||||
|
* operationId="listAdminProviders",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=2),
|
||||||
|
* @OA\Property(property="name", type="string", example="林教練"),
|
||||||
|
* @OA\Property(property="email", type="string", example="coach@example.com"),
|
||||||
|
* @OA\Property(property="role", type="string", example="provider"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="is_verified", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="provider_profile", type="object", nullable=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminProviders()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一教練
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/providers/{id}",
|
||||||
|
* summary="取得單一教練",
|
||||||
|
* operationId="getAdminProvider",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="object")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getAdminProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切換教練啟用狀態
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/providers/{id}/toggle-active",
|
||||||
|
* summary="切換教練啟用狀態",
|
||||||
|
* operationId="toggleProviderActive",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="切換成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="帳號已停用"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function toggleProviderActive()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教練審核佇列
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/verifications",
|
||||||
|
* summary="教練審核佇列",
|
||||||
|
* description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
|
||||||
|
* operationId="adminVerificationIndex",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="status", in="query", required=false, description="unsubmitted / pending / approved / rejected / all", @OA\Schema(type="string", default="pending")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="查詢成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(
|
||||||
|
* @OA\Property(property="user_id", type="integer", example=5),
|
||||||
|
* @OA\Property(property="name", type="string", example="王教練"),
|
||||||
|
* @OA\Property(property="email", type="string", example="coach@example.com"),
|
||||||
|
* @OA\Property(property="business_name", type="string", example="藍海潛水"),
|
||||||
|
* @OA\Property(property="verification_status", type="string", example="pending"),
|
||||||
|
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="certifications", type="array", @OA\Items(
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
|
||||||
|
* ))
|
||||||
|
* ))
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function adminVerificationIndex()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通過教練審核
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/verifications/{userId}/approve",
|
||||||
|
* summary="通過教練審核",
|
||||||
|
* description="將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
|
||||||
|
* operationId="adminVerificationApprove",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(response=200, description="已通過審核"),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="當前狀態無法通過審核", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function adminVerificationApprove()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 駁回教練審核
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/verifications/{userId}/reject",
|
||||||
|
* summary="駁回教練審核",
|
||||||
|
* description="駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
|
||||||
|
* operationId="adminVerificationReject",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||||
|
* required={"reason"},
|
||||||
|
* @OA\Property(property="reason", type="string", maxLength=500, example="證照影像不清晰,請重新拍攝上傳")
|
||||||
|
* )),
|
||||||
|
* @OA\Response(response=200, description="已駁回"),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="原因未填或狀態不可駁回", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function adminVerificationReject()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認教練存在
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/check-provider/{id}",
|
||||||
|
* summary="確認教練存在",
|
||||||
|
* operationId="checkProvider",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="查詢成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="exists", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function checkProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Offers / Bookings / Reviews
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得所有課程(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/offers",
|
||||||
|
* summary="取得所有課程(Admin)",
|
||||||
|
* description="分頁回傳全平台課程列表",
|
||||||
|
* operationId="listAdminOffers",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminOffers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除課程(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/admin/offers/{id}",
|
||||||
|
* summary="刪除課程(Admin)",
|
||||||
|
* operationId="deleteAdminOffer",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteAdminOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得所有預約(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/bookings",
|
||||||
|
* summary="取得所有預約(Admin)",
|
||||||
|
* description="分頁回傳全平台預約列表",
|
||||||
|
* operationId="listAdminBookings",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminBookings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記預約完成(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/bookings/{id}/complete",
|
||||||
|
* summary="標記預約完成(Admin)",
|
||||||
|
* operationId="completeBookingByAdmin",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function completeBookingByAdmin()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得所有評價(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/reviews",
|
||||||
|
* summary="取得所有評價(Admin)",
|
||||||
|
* description="分頁回傳全平台評價列表,per_page 最大 100",
|
||||||
|
* operationId="listAdminReviews",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(最大 100)", @OA\Schema(type="integer", default=20, maximum=100)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Review")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminReviews()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除評價(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/admin/reviews/{id}",
|
||||||
|
* summary="刪除評價(Admin)",
|
||||||
|
* operationId="deleteAdminReview",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteAdminReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
-73
@@ -12,7 +12,7 @@ use OpenApi\Annotations as OA;
|
|||||||
* )
|
* )
|
||||||
*
|
*
|
||||||
* @OA\Server(
|
* @OA\Server(
|
||||||
* url="/api",
|
* url=L5_SWAGGER_CONST_HOST,
|
||||||
* description="API 伺服器"
|
* description="API 伺服器"
|
||||||
* )
|
* )
|
||||||
*
|
*
|
||||||
@@ -35,6 +35,10 @@ use OpenApi\Annotations as OA;
|
|||||||
* name="管理員",
|
* name="管理員",
|
||||||
* description="管理員相關操作"
|
* description="管理員相關操作"
|
||||||
* )
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="認證",
|
||||||
|
* description="通用認證操作(登出、取得當前使用者)"
|
||||||
|
* )
|
||||||
*/
|
*/
|
||||||
class AuthApiDoc
|
class AuthApiDoc
|
||||||
{
|
{
|
||||||
@@ -42,7 +46,7 @@ class AuthApiDoc
|
|||||||
* 會員註冊
|
* 會員註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/member",
|
* path="/member/register",
|
||||||
* summary="會員註冊",
|
* summary="會員註冊",
|
||||||
* description="建立新的會員帳號",
|
* description="建立新的會員帳號",
|
||||||
* operationId="registerMember",
|
* operationId="registerMember",
|
||||||
@@ -119,7 +123,7 @@ class AuthApiDoc
|
|||||||
* 會員登入
|
* 會員登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/member",
|
* path="/member/login",
|
||||||
* summary="會員登入",
|
* summary="會員登入",
|
||||||
* description="會員帳號登入系統",
|
* description="會員帳號登入系統",
|
||||||
* operationId="loginMember",
|
* operationId="loginMember",
|
||||||
@@ -203,11 +207,43 @@ class AuthApiDoc
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 會員 Token 刷新
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/member/refresh",
|
||||||
|
* summary="會員 Token 刷新",
|
||||||
|
* description="撤銷當前 Token 並發放新的 Bearer token(不需要重新登入)",
|
||||||
|
* operationId="refreshMember",
|
||||||
|
* tags={"會員"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刷新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="token", type="string", example="24|newtoken..."),
|
||||||
|
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function refreshMember()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 會員登出
|
* 會員登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/member",
|
* path="/member/logout",
|
||||||
* summary="會員登出",
|
* summary="會員登出",
|
||||||
* description="會員登出系統並撤銷當前令牌",
|
* description="會員登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutMember",
|
* operationId="logoutMember",
|
||||||
@@ -241,7 +277,7 @@ class AuthApiDoc
|
|||||||
* 取得會員個人資料
|
* 取得會員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/member",
|
* path="/member/profile",
|
||||||
* summary="取得會員個人資料",
|
* summary="取得會員個人資料",
|
||||||
* description="取得當前登入會員的個人資料",
|
* description="取得當前登入會員的個人資料",
|
||||||
* operationId="memberProfile",
|
* operationId="memberProfile",
|
||||||
@@ -303,7 +339,7 @@ class AuthApiDoc
|
|||||||
* 更新會員個人資料
|
* 更新會員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/member",
|
* path="/member/profile",
|
||||||
* summary="更新會員個人資料",
|
* summary="更新會員個人資料",
|
||||||
* description="更新當前登入會員的個人資料",
|
* description="更新當前登入會員的個人資料",
|
||||||
* operationId="updateMemberProfile",
|
* operationId="updateMemberProfile",
|
||||||
@@ -383,8 +419,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改會員密碼
|
* 修改會員密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/member",
|
* path="/member/change-password",
|
||||||
* summary="修改會員密碼",
|
* summary="修改會員密碼",
|
||||||
* description="修改當前登入會員的密碼",
|
* description="修改當前登入會員的密碼",
|
||||||
* operationId="changeMemberPassword",
|
* operationId="changeMemberPassword",
|
||||||
@@ -444,7 +480,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者註冊
|
* 服務提供者註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/provider",
|
* path="/provider/register",
|
||||||
* summary="服務提供者註冊",
|
* summary="服務提供者註冊",
|
||||||
* description="建立新的服務提供者帳號",
|
* description="建立新的服務提供者帳號",
|
||||||
* operationId="registerProvider",
|
* operationId="registerProvider",
|
||||||
@@ -498,7 +534,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者登入
|
* 服務提供者登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/provider",
|
* path="/provider/login",
|
||||||
* summary="服務提供者登入",
|
* summary="服務提供者登入",
|
||||||
* description="服務提供者帳號登入系統",
|
* description="服務提供者帳號登入系統",
|
||||||
* operationId="loginProvider",
|
* operationId="loginProvider",
|
||||||
@@ -576,11 +612,43 @@ class AuthApiDoc
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服務提供者 Token 刷新
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/refresh",
|
||||||
|
* summary="服務提供者 Token 刷新",
|
||||||
|
* description="撤銷當前 Token 並發放新的 Bearer token",
|
||||||
|
* operationId="refreshProvider",
|
||||||
|
* tags={"服務提供者"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刷新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="token", type="string", example="25|newtoken..."),
|
||||||
|
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function refreshProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服務提供者登出
|
* 服務提供者登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/provider",
|
* path="/provider/logout",
|
||||||
* summary="服務提供者登出",
|
* summary="服務提供者登出",
|
||||||
* description="服務提供者登出系統並撤銷當前令牌",
|
* description="服務提供者登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutProvider",
|
* operationId="logoutProvider",
|
||||||
@@ -614,7 +682,7 @@ class AuthApiDoc
|
|||||||
* 取得服務提供者資料
|
* 取得服務提供者資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/provider",
|
* path="/provider/profile",
|
||||||
* summary="取得服務提供者資料",
|
* summary="取得服務提供者資料",
|
||||||
* description="取得當前登入服務提供者的資料",
|
* description="取得當前登入服務提供者的資料",
|
||||||
* operationId="providerProfile",
|
* operationId="providerProfile",
|
||||||
@@ -678,7 +746,7 @@ class AuthApiDoc
|
|||||||
* 更新服務提供者資料
|
* 更新服務提供者資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/provider",
|
* path="/provider/profile",
|
||||||
* summary="更新服務提供者資料",
|
* summary="更新服務提供者資料",
|
||||||
* description="更新當前登入服務提供者的資料",
|
* description="更新當前登入服務提供者的資料",
|
||||||
* operationId="updateProviderProfile",
|
* operationId="updateProviderProfile",
|
||||||
@@ -764,8 +832,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改服務提供者密碼
|
* 修改服務提供者密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/provider",
|
* path="/provider/change-password",
|
||||||
* summary="修改服務提供者密碼",
|
* summary="修改服務提供者密碼",
|
||||||
* description="修改當前登入服務提供者的密碼",
|
* description="修改當前登入服務提供者的密碼",
|
||||||
* operationId="changeProviderPassword",
|
* operationId="changeProviderPassword",
|
||||||
@@ -821,63 +889,11 @@ class AuthApiDoc
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理員註冊
|
|
||||||
*
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/register/admin",
|
|
||||||
* summary="管理員註冊",
|
|
||||||
* description="建立新的管理員帳號",
|
|
||||||
* operationId="registerAdmin",
|
|
||||||
* tags={"管理員"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"name", "email", "password", "password_confirmation"},
|
|
||||||
* @OA\Property(property="name", type="string", example="張管理", description="使用者姓名"),
|
|
||||||
* @OA\Property(property="email", type="string", format="email", example="admin@example.com", description="電子郵件"),
|
|
||||||
* @OA\Property(property="password", type="string", format="password", example="password123", description="密碼"),
|
|
||||||
* @OA\Property(property="password_confirmation", type="string", format="password", example="password123", description="確認密碼"),
|
|
||||||
* @OA\Property(property="phone", type="string", example="0912345678", description="電話號碼"),
|
|
||||||
* @OA\Property(property="position", type="string", example="系統管理員", description="職位"),
|
|
||||||
* @OA\Property(property="department", type="string", example="IT部門", description="部門")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=201,
|
|
||||||
* description="管理員註冊成功",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="status", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="管理員註冊成功"),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="data",
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="user", type="object", ref="#/components/schemas/User"),
|
|
||||||
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
|
|
||||||
* @OA\Property(property="token_type", type="string", example="Bearer")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=422,
|
|
||||||
* description="驗證失敗",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="status", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="message", type="string", example="驗證失敗"),
|
|
||||||
* @OA\Property(property="errors", type="object")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function registerAdmin()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理員登入
|
* 管理員登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/admin",
|
* path="/admin/login",
|
||||||
* summary="管理員登入",
|
* summary="管理員登入",
|
||||||
* description="管理員帳號登入系統",
|
* description="管理員帳號登入系統",
|
||||||
* operationId="loginAdmin",
|
* operationId="loginAdmin",
|
||||||
@@ -954,11 +970,43 @@ class AuthApiDoc
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理員 Token 刷新
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/admin/refresh",
|
||||||
|
* summary="管理員 Token 刷新",
|
||||||
|
* description="撤銷當前 Token 並發放新的 Bearer token",
|
||||||
|
* operationId="refreshAdmin",
|
||||||
|
* tags={"管理員"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刷新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="token", type="string", example="26|newtoken..."),
|
||||||
|
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function refreshAdmin()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理員登出
|
* 管理員登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/admin",
|
* path="/admin/logout",
|
||||||
* summary="管理員登出",
|
* summary="管理員登出",
|
||||||
* description="管理員登出系統並撤銷當前令牌",
|
* description="管理員登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutAdmin",
|
* operationId="logoutAdmin",
|
||||||
@@ -992,7 +1040,7 @@ class AuthApiDoc
|
|||||||
* 取得管理員個人資料
|
* 取得管理員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/admin",
|
* path="/admin/profile",
|
||||||
* summary="取得管理員個人資料",
|
* summary="取得管理員個人資料",
|
||||||
* description="取得當前登入管理員的個人資料",
|
* description="取得當前登入管理員的個人資料",
|
||||||
* operationId="adminProfile",
|
* operationId="adminProfile",
|
||||||
@@ -1030,7 +1078,7 @@ class AuthApiDoc
|
|||||||
* 更新管理員個人資料
|
* 更新管理員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/admin",
|
* path="/admin/profile",
|
||||||
* summary="更新管理員個人資料",
|
* summary="更新管理員個人資料",
|
||||||
* description="更新當前登入管理員的個人資料",
|
* description="更新當前登入管理員的個人資料",
|
||||||
* operationId="updateAdminProfile",
|
* operationId="updateAdminProfile",
|
||||||
@@ -1087,8 +1135,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改管理員密碼
|
* 修改管理員密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/admin",
|
* path="/admin/change-password",
|
||||||
* summary="修改管理員密碼",
|
* summary="修改管理員密碼",
|
||||||
* description="修改當前登入管理員的密碼",
|
* description="修改當前登入管理員的密碼",
|
||||||
* operationId="changeAdminPassword",
|
* operationId="changeAdminPassword",
|
||||||
@@ -1143,4 +1191,71 @@ class AuthApiDoc
|
|||||||
public function changeAdminPassword()
|
public function changeAdminPassword()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用登出
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/logout",
|
||||||
|
* summary="通用登出",
|
||||||
|
* description="撤銷當前 Bearer token,適用所有角色",
|
||||||
|
* operationId="logout",
|
||||||
|
* tags={"認證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="登出成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="登出成功")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function logout()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得當前使用者
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/user",
|
||||||
|
* summary="取得當前使用者",
|
||||||
|
* description="回傳當前 Bearer token 所屬的使用者資訊",
|
||||||
|
* operationId="currentUser",
|
||||||
|
* tags={"認證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="name", type="string", example="王小明"),
|
||||||
|
* @OA\Property(property="email", type="string", example="user@example.com"),
|
||||||
|
* @OA\Property(property="role", type="string", enum={"member","provider","admin"}, example="member"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function currentUser()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,568 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="會員預約",
|
||||||
|
* description="會員的預約管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="會員評價",
|
||||||
|
* description="會員的評價管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="通知",
|
||||||
|
* description="站內通知管理(Member / Provider 共用)"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="即時訊息",
|
||||||
|
* description="預約聊天室訊息(Member / Provider 共用,僅限 confirmed / completed 預約的參與方)"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class MemberApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Member Bookings
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立預約
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/member/bookings",
|
||||||
|
* summary="建立預約",
|
||||||
|
* description="會員建立新的課程預約",
|
||||||
|
* operationId="createBooking",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"offer_id","schedule_id","participants"},
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
|
||||||
|
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
|
||||||
|
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
|
||||||
|
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="預約建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約成功"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=422,
|
||||||
|
* description="驗證失敗或時段已滿",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="無權限(非 member 角色)",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得我的預約列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/member/bookings",
|
||||||
|
* summary="取得我的預約列表",
|
||||||
|
* description="分頁回傳當前會員的所有預約",
|
||||||
|
* operationId="listMemberBookings",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listMemberBookings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一預約
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/member/bookings/{id}",
|
||||||
|
* summary="取得單一預約",
|
||||||
|
* operationId="getMemberBooking",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getMemberBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消預約
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/member/bookings/{id}",
|
||||||
|
* summary="取消預約",
|
||||||
|
* description="會員取消自己的預約",
|
||||||
|
* operationId="cancelBooking",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取消成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已取消")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function cancelBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Member Reviews
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立評價
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/member/reviews",
|
||||||
|
* summary="建立評價",
|
||||||
|
* description="會員對已完成的預約課程提交評價",
|
||||||
|
* operationId="createReview",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"booking_id","rating"},
|
||||||
|
* @OA\Property(property="booking_id", type="integer", example=5),
|
||||||
|
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
|
||||||
|
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
|
||||||
|
* @OA\Property(property="is_anonymous", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="評價建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="評價已提交"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新評價
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/member/reviews/{id}",
|
||||||
|
* summary="更新評價",
|
||||||
|
* operationId="updateReview",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
|
||||||
|
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
|
||||||
|
* @OA\Property(property="is_anonymous", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="更新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function updateReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除評價
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/member/reviews/{id}",
|
||||||
|
* summary="刪除評價",
|
||||||
|
* operationId="deleteReview",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記評價為有幫助
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/reviews/{id}/helpful",
|
||||||
|
* summary="標記評價為有幫助",
|
||||||
|
* description="切換投票狀態(已投票則撤回)",
|
||||||
|
* operationId="markReviewHelpful",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="操作成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="helpful_count", type="integer", example=4),
|
||||||
|
* @OA\Property(property="has_voted", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markReviewHelpful()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Booking Messages(即時訊息,Member + Provider 共用)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得各預約未讀訊息數
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/bookings/messages/unread-counts",
|
||||||
|
* summary="取得各預約未讀訊息數",
|
||||||
|
* description="回傳當前使用者每個預約中對方發送的未讀訊息數量(Key 為 booking_id)",
|
||||||
|
* operationId="getMessageUnreadCounts",
|
||||||
|
* tags={"即時訊息"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* description="Key 為 booking_id(string),value 為未讀數(integer)",
|
||||||
|
* example={"12": 3, "17": 1}
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getMessageUnreadCounts()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得預約訊息列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/bookings/{booking}/messages",
|
||||||
|
* summary="取得預約訊息列表",
|
||||||
|
* description="回傳指定預約的所有訊息(依時間升冪排列),僅限預約參與方存取,且預約狀態需為 confirmed 或 completed",
|
||||||
|
* operationId="listBookingMessages",
|
||||||
|
* tags={"即時訊息"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="booking_id", type="integer", example=12),
|
||||||
|
* @OA\Property(property="sender_type", type="string", enum={"member","provider"}, example="member"),
|
||||||
|
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
|
||||||
|
* @OA\Property(property="image_url", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-07-01T10:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listBookingMessages()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 發送預約訊息
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/bookings/{booking}/messages",
|
||||||
|
* summary="發送預約訊息",
|
||||||
|
* description="向指定預約的對方發送文字訊息或圖片(multipart/form-data)",
|
||||||
|
* operationId="sendBookingMessage",
|
||||||
|
* tags={"即時訊息"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
|
||||||
|
* @OA\Property(property="image", type="string", format="binary", nullable=true, description="圖片(jpg/png,message 與 image 至少擇一)")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="訊息已送出",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=42),
|
||||||
|
* @OA\Property(property="booking_id", type="integer", example=12),
|
||||||
|
* @OA\Property(property="sender_type", type="string", example="member"),
|
||||||
|
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
|
||||||
|
* @OA\Property(property="image_url", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-07-01T10:05:00.000000Z")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="message 與 image 皆為空", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function sendBookingMessage()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記訊息為已讀
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/bookings/{booking}/messages/read",
|
||||||
|
* summary="標記訊息為已讀",
|
||||||
|
* description="將指定預約中對方發送的所有未讀訊息批次標記為已讀",
|
||||||
|
* operationId="markMessagesRead",
|
||||||
|
* tags={"即時訊息"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="已標記為已讀")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非參與方", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markMessagesRead()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Notifications (Member + Provider 共用)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得通知列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/notifications",
|
||||||
|
* summary="取得通知列表",
|
||||||
|
* description="分頁回傳當前使用者的所有通知(Member / Provider 共用)",
|
||||||
|
* operationId="listNotifications",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="type", type="string", example="booking_confirmed"),
|
||||||
|
* @OA\Property(property="title", type="string", example="預約已確認"),
|
||||||
|
* @OA\Property(property="message", type="string", example="您的預約已由教練確認"),
|
||||||
|
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listNotifications()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得未讀通知數量
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/notifications/unread-count",
|
||||||
|
* summary="取得未讀通知數量",
|
||||||
|
* operationId="getUnreadNotificationCount",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="unread_count", type="integer", example=3)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getUnreadNotificationCount()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記單一通知為已讀
|
||||||
|
*
|
||||||
|
* @OA\Patch(
|
||||||
|
* path="/notifications/{id}/read",
|
||||||
|
* summary="標記單一通知為已讀",
|
||||||
|
* operationId="markNotificationRead",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markNotificationRead()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部通知標記為已讀
|
||||||
|
*
|
||||||
|
* @OA\Patch(
|
||||||
|
* path="/notifications/read-all",
|
||||||
|
* summary="全部通知標記為已讀",
|
||||||
|
* operationId="markAllNotificationsRead",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markAllNotificationsRead()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除通知
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/notifications/{id}",
|
||||||
|
* summary="刪除通知",
|
||||||
|
* operationId="deleteNotification",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="通知已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteNotification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練課程",
|
||||||
|
* description="服務提供者的課程管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練圖片",
|
||||||
|
* description="服務提供者的課程圖片管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練時段",
|
||||||
|
* description="服務提供者的課程時段管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練預約",
|
||||||
|
* description="服務提供者的預約管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練驗證",
|
||||||
|
* description="服務提供者的資格驗證申請(證照送審)"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class ProviderApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Verification (證照送審)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得驗證申請狀態
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/verification",
|
||||||
|
* summary="取得驗證申請狀態",
|
||||||
|
* description="回傳當前服務提供者的驗證狀態與已上傳的證照清單",
|
||||||
|
* operationId="getProviderVerification",
|
||||||
|
* tags={"教練驗證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="verification_status", type="string", enum={"unsubmitted","pending","approved","rejected"}, example="pending"),
|
||||||
|
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="certifications",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getProviderVerification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上傳證照圖片
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/verification/certifications",
|
||||||
|
* summary="上傳證照圖片",
|
||||||
|
* description="上傳一張證照圖片(multipart/form-data),未送審前可多次上傳",
|
||||||
|
* operationId="uploadCertification",
|
||||||
|
* tags={"教練驗證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"certification"},
|
||||||
|
* @OA\Property(property="certification", type="string", format="binary", description="證照圖片(jpg/png,最大 5MB)")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="上傳成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function uploadCertification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除證照圖片
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/verification/certifications/{id}",
|
||||||
|
* summary="刪除證照圖片",
|
||||||
|
* description="刪除指定的已上傳證照,已送審(pending/approved)狀態不可刪除",
|
||||||
|
* operationId="deleteCertification",
|
||||||
|
* tags={"教練驗證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="證照 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="證照已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="無權限或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteCertification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 送出驗證申請
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/verification/submit",
|
||||||
|
* summary="送出驗證申請",
|
||||||
|
* description="將驗證狀態由 unsubmitted/rejected 轉為 pending,至少需有一張已上傳的證照",
|
||||||
|
* operationId="submitVerification",
|
||||||
|
* tags={"教練驗證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="送審成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="驗證申請已送出")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function submitVerification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Offers CRUD
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得自己的課程列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/offers",
|
||||||
|
* summary="取得自己的課程列表",
|
||||||
|
* description="回傳當前服務提供者的所有課程(分頁)",
|
||||||
|
* operationId="listProviderOffers",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listProviderOffers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立課程
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/offers",
|
||||||
|
* summary="建立課程",
|
||||||
|
* description="服務提供者建立新的潛水課程",
|
||||||
|
* operationId="createProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"title","description","region","price","max_participants"},
|
||||||
|
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
|
||||||
|
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
|
||||||
|
* @OA\Property(property="region", type="string", example="墾丁"),
|
||||||
|
* @OA\Property(property="price", type="number", format="float", example=8500),
|
||||||
|
* @OA\Property(property="max_participants", type="integer", example=6),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="課程建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="課程建立成功"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一課程(Provider 視角)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/offers/{id}",
|
||||||
|
* summary="取得單一課程(Provider 視角)",
|
||||||
|
* description="取得自己的指定課程,非本人課程回傳 403",
|
||||||
|
* operationId="getProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新課程
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/offers/{id}",
|
||||||
|
* summary="更新課程",
|
||||||
|
* operationId="updateProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
|
||||||
|
* @OA\Property(property="description", type="string", example="AOWD 課程"),
|
||||||
|
* @OA\Property(property="region", type="string", example="小琉球"),
|
||||||
|
* @OA\Property(property="price", type="number", format="float", example=12000),
|
||||||
|
* @OA\Property(property="max_participants", type="integer", example=4),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"進階","AOWD"}),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="更新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function updateProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除課程
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/offers/{id}",
|
||||||
|
* summary="刪除課程",
|
||||||
|
* operationId="deleteProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Offer Images
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上傳封面圖片
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/offers/{id}/cover",
|
||||||
|
* summary="上傳封面圖片",
|
||||||
|
* description="上傳課程封面圖片(multipart/form-data)",
|
||||||
|
* operationId="uploadOfferCover",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"cover_image"},
|
||||||
|
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB)")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="上傳成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function uploadOfferCover()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除封面圖片
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/offers/{id}/cover",
|
||||||
|
* summary="刪除封面圖片",
|
||||||
|
* operationId="deleteOfferCover",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteOfferCover()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上傳相簿圖片
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/offers/{id}/images",
|
||||||
|
* summary="上傳相簿圖片",
|
||||||
|
* description="新增課程相簿圖片(multipart/form-data,可多張)",
|
||||||
|
* operationId="uploadOfferImages",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"images[]"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="images[]",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(type="string", format="binary"),
|
||||||
|
* description="相簿圖片(每張最大 5MB)"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="上傳成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function uploadOfferImages()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除相簿圖片
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/images/{id}",
|
||||||
|
* summary="刪除相簿圖片",
|
||||||
|
* operationId="deleteOfferImage",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="圖片已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteOfferImage()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Schedules
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得時段列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/schedules",
|
||||||
|
* summary="取得時段列表",
|
||||||
|
* description="回傳服務提供者的所有時段,可依 offer_id 篩選",
|
||||||
|
* operationId="listProviderSchedules",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listProviderSchedules()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立時段
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/schedules",
|
||||||
|
* summary="建立時段",
|
||||||
|
* operationId="createProviderSchedule",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"offer_id","start_date","end_date","available_slots"},
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
|
||||||
|
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
|
||||||
|
* @OA\Property(property="available_slots", type="integer", example=4)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="時段建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createProviderSchedule()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新時段
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/schedules/{id}",
|
||||||
|
* summary="更新時段",
|
||||||
|
* operationId="updateProviderSchedule",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
|
||||||
|
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
|
||||||
|
* @OA\Property(property="available_slots", type="integer", example=6),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="更新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function updateProviderSchedule()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除時段
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/schedules/{id}",
|
||||||
|
* summary="刪除時段",
|
||||||
|
* operationId="deleteProviderSchedule",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="時段已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteProviderSchedule()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Bookings Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得收到的預約列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/bookings",
|
||||||
|
* summary="取得收到的預約列表",
|
||||||
|
* description="分頁回傳服務提供者收到的所有預約",
|
||||||
|
* operationId="listProviderBookings",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listProviderBookings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認預約
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/confirm",
|
||||||
|
* summary="確認預約",
|
||||||
|
* operationId="confirmBooking",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="確認成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已確認"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function confirmBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒絕預約
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/reject",
|
||||||
|
* summary="拒絕預約",
|
||||||
|
* operationId="rejectBooking",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="拒絕成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已拒絕"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function rejectBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記預約完成
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/complete",
|
||||||
|
* summary="標記預約完成",
|
||||||
|
* operationId="completeBookingByProvider",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function completeBookingByProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消預約(Provider)
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/cancel",
|
||||||
|
* summary="取消預約(Provider)",
|
||||||
|
* operationId="cancelBookingByProvider",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取消成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已取消"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function cancelBookingByProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum VerificationStatus: string
|
||||||
|
{
|
||||||
|
case Unsubmitted = 'unsubmitted';
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Approved = 'approved';
|
||||||
|
case Rejected = 'rejected';
|
||||||
|
|
||||||
|
public const VALID_TRANSITIONS = [
|
||||||
|
'unsubmitted' => ['pending'],
|
||||||
|
'pending' => ['approved', 'rejected'],
|
||||||
|
'approved' => ['rejected'], // 撤銷驗證,原因必填
|
||||||
|
'rejected' => ['pending'], // 重新送審
|
||||||
|
];
|
||||||
|
|
||||||
|
public function canTransitionTo(self $newStatus): bool
|
||||||
|
{
|
||||||
|
return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageRead implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $bookingId,
|
||||||
|
public string $readerType,
|
||||||
|
public int $lastReadMessageId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'MessageRead';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PresenceChannel('booking.' . $this->bookingId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reader_type' => $this->readerType,
|
||||||
|
'last_read_message_id' => $this->lastReadMessageId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\BookingMessage;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageSent implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public BookingMessage $message) {}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'MessageSent';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PresenceChannel('booking.' . $this->message->booking_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->message->id,
|
||||||
|
'sender_id' => $this->message->sender_id,
|
||||||
|
'sender_type' => $this->message->sender_type,
|
||||||
|
'type' => $this->message->type,
|
||||||
|
'content' => $this->message->content,
|
||||||
|
'created_at' => $this->message->created_at->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class NotificationCreated implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets;
|
||||||
|
|
||||||
|
public function __construct(public readonly int $userId) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
// 使用 Laravel 內建的 User private channel(channels.php 已有 auth)
|
||||||
|
return [new PrivateChannel("App.Models.User.{$this->userId}")];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'notification.created';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不需要 payload,前端收到後直接呼叫 fetchUnreadCount()
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,20 +131,5 @@ class AdminUserController extends Controller
|
|||||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleProviderVerified(int $id)
|
// toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機
|
||||||
{
|
|
||||||
if ($err = $this->checkAdmin()) return $err;
|
|
||||||
|
|
||||||
$user = $this->findUser($id, 'provider');
|
|
||||||
if (!$user) {
|
|
||||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$profile = $user->providerProfile;
|
|
||||||
$profile->is_verified = !$profile->is_verified;
|
|
||||||
$profile->save();
|
|
||||||
|
|
||||||
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
|
|
||||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Enums\VerificationStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ProviderVerificationApprovedNotification;
|
||||||
|
use App\Notifications\ProviderVerificationRejectedNotification;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class AdminVerificationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$status = $request->query('status', VerificationStatus::Pending->value);
|
||||||
|
|
||||||
|
$query = User::where('role', 'provider')
|
||||||
|
->with(['providerProfile', 'providerCertifications'])
|
||||||
|
->whereHas('providerProfile');
|
||||||
|
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->whereHas('providerProfile', fn($q) => $q->where('verification_status', $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
$providers = $query->orderByDesc('updated_at')->get()->map(fn($u) => [
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'name' => $u->name,
|
||||||
|
'email' => $u->email,
|
||||||
|
'business_name' => $u->providerProfile->business_name,
|
||||||
|
'verification_status' => $u->providerProfile->verification_status->value,
|
||||||
|
'rejection_reason' => $u->providerProfile->rejection_reason,
|
||||||
|
'certifications' => $u->providerCertifications->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $providers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(Request $request, int $userId)
|
||||||
|
{
|
||||||
|
$profile = $this->findProviderProfile($userId);
|
||||||
|
|
||||||
|
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Approved)) {
|
||||||
|
return response()->json(['status' => false, 'message' => '當前狀態無法通過審核'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile->update(['verification_status' => VerificationStatus::Approved, 'rejection_reason' => null]);
|
||||||
|
|
||||||
|
// 驗證狀態影響公開課程可見性,需立即讓快取失效
|
||||||
|
Cache::tags(['diving_offers'])->flush();
|
||||||
|
|
||||||
|
$this->notify($profile->user, new ProviderVerificationApprovedNotification());
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'message' => '教練已通過審核']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(Request $request, int $userId)
|
||||||
|
{
|
||||||
|
$data = $request->validate(['reason' => 'required|string|max:500']);
|
||||||
|
|
||||||
|
$profile = $this->findProviderProfile($userId);
|
||||||
|
|
||||||
|
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Rejected)) {
|
||||||
|
return response()->json(['status' => false, 'message' => '當前狀態無法駁回'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile->update([
|
||||||
|
'verification_status' => VerificationStatus::Rejected,
|
||||||
|
'rejection_reason' => $data['reason'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::tags(['diving_offers'])->flush();
|
||||||
|
|
||||||
|
$this->notify($profile->user, new ProviderVerificationRejectedNotification($data['reason']));
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'message' => '已駁回,教練將收到原因通知']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findProviderProfile(int $userId)
|
||||||
|
{
|
||||||
|
return User::where('role', 'provider')->findOrFail($userId)->providerProfile()->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notify(User $provider, object $notification): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$provider->notify($notification);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('ProviderVerification notification failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,19 @@ use App\Models\User;
|
|||||||
use App\Models\AdminProfile;
|
use App\Models\AdminProfile;
|
||||||
use App\Models\ProviderProfile;
|
use App\Models\ProviderProfile;
|
||||||
use App\Models\MemberProfile;
|
use App\Models\MemberProfile;
|
||||||
|
use App\Traits\NormalizesEmail;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
// 科定規範角色
|
use NormalizesEmail;
|
||||||
|
|
||||||
private const ROLE_MEMBER = 'member';
|
private const ROLE_MEMBER = 'member';
|
||||||
private const ROLE_PROVIDER = 'provider';
|
private const ROLE_PROVIDER = 'provider';
|
||||||
private const ROLE_ADMIN = 'admin';
|
private const ROLE_ADMIN = 'admin';
|
||||||
@@ -131,6 +135,50 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAccountLocked(string $role, string $email): bool
|
||||||
|
{
|
||||||
|
$count = Cache::get("login_failures:{$role}:{$email}", 0);
|
||||||
|
return $count >= config('auth_lockout.max_attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordLoginFailure(string $role, string $email): int
|
||||||
|
{
|
||||||
|
$ttl = config('auth_lockout.decay_minutes') * 60;
|
||||||
|
$countKey = "login_failures:{$role}:{$email}";
|
||||||
|
$expiresKey = "login_expires_at:{$role}:{$email}";
|
||||||
|
|
||||||
|
if (!Cache::has($countKey)) {
|
||||||
|
Cache::put($countKey, 1, $ttl);
|
||||||
|
Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cache::increment($countKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearLoginFailures(string $role, string $email): void
|
||||||
|
{
|
||||||
|
Cache::forget("login_failures:{$role}:{$email}");
|
||||||
|
Cache::forget("login_expires_at:{$role}:{$email}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$decayMinutes = config('auth_lockout.decay_minutes');
|
||||||
|
$lockedUntil = Cache::get("login_expires_at:{$role}:{$email}");
|
||||||
|
|
||||||
|
if (!$lockedUntil) {
|
||||||
|
Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}");
|
||||||
|
$lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試",
|
||||||
|
'locked_until' => $lockedUntil,
|
||||||
|
], 423);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 標準化的登出方法
|
* 標準化的登出方法
|
||||||
*/
|
*/
|
||||||
@@ -268,7 +316,6 @@ class AuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function loginMember(Request $request)
|
public function loginMember(Request $request)
|
||||||
{
|
{
|
||||||
// 驗證請求數據
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'email' => 'required|string|email',
|
'email' => 'required|string|email',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
@@ -282,20 +329,30 @@ class AuthController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否存在
|
$email = $this->normalizeEmail($request->email);
|
||||||
$user = User::where('email', $request->email)
|
|
||||||
->where('role', 'member') // 只驗證會員
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// 檢查密碼
|
// 1st: 帳號鎖定檢查
|
||||||
|
if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2nd: 帳號存在確認
|
||||||
|
$user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first();
|
||||||
|
|
||||||
|
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||||
|
if ($user) {
|
||||||
|
$count = $this->recordLoginFailure(self::ROLE_MEMBER, $email);
|
||||||
|
if ($count >= config('auth_lockout.max_attempts')) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||||
|
}
|
||||||
|
}
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '電子郵件或密碼錯誤'
|
'message' => '電子郵件或密碼錯誤'
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否啟用
|
|
||||||
if (!$user->is_active) {
|
if (!$user->is_active) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
@@ -303,10 +360,8 @@ class AuthController extends Controller
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 創建 API token
|
$this->clearLoginFailures(self::ROLE_MEMBER, $email);
|
||||||
$token = $user->createToken('auth_token')->plainTextToken;
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
// 加載會員資料
|
|
||||||
$user->load('memberProfile');
|
$user->load('memberProfile');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -531,7 +586,6 @@ class AuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function loginProvider(Request $request)
|
public function loginProvider(Request $request)
|
||||||
{
|
{
|
||||||
// 驗證請求數據
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'email' => 'required|string|email',
|
'email' => 'required|string|email',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
@@ -545,20 +599,30 @@ class AuthController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否存在
|
$email = $this->normalizeEmail($request->email);
|
||||||
$user = User::where('email', $request->email)
|
|
||||||
->where('role', 'provider') // 只驗證服務提供者
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// 檢查密碼
|
// 1st: 帳號鎖定檢查
|
||||||
|
if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2nd: 帳號存在確認
|
||||||
|
$user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first();
|
||||||
|
|
||||||
|
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||||
|
if ($user) {
|
||||||
|
$count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email);
|
||||||
|
if ($count >= config('auth_lockout.max_attempts')) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||||
|
}
|
||||||
|
}
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '電子郵件或密碼錯誤'
|
'message' => '電子郵件或密碼錯誤'
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否啟用
|
|
||||||
if (!$user->is_active) {
|
if (!$user->is_active) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
@@ -566,10 +630,8 @@ class AuthController extends Controller
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 創建 API token
|
$this->clearLoginFailures(self::ROLE_PROVIDER, $email);
|
||||||
$token = $user->createToken('auth_token')->plainTextToken;
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
// 加載服務提供者資料
|
|
||||||
$user->load('providerProfile');
|
$user->load('providerProfile');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -784,59 +846,6 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理員註冊
|
|
||||||
*/
|
|
||||||
public function registerAdmin(Request $request)
|
|
||||||
{
|
|
||||||
// 驗證請求數據
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'email' => 'required|string|email|max:255|unique:users',
|
|
||||||
'password' => 'required|string|min:6|confirmed',
|
|
||||||
'phone' => 'nullable|string|max:20',
|
|
||||||
'position' => 'nullable|string|max:100',
|
|
||||||
'department' => 'nullable|string|max:100',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => false,
|
|
||||||
'message' => '驗證失敗',
|
|
||||||
'errors' => $validator->errors()
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 創建用戶
|
|
||||||
$user = User::create([
|
|
||||||
'name' => $request->name,
|
|
||||||
'email' => $request->email,
|
|
||||||
'password' => Hash::make($request->password),
|
|
||||||
'phone' => $request->phone,
|
|
||||||
'role' => 'admin', // 強制為管理員角色
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 創建管理員資料
|
|
||||||
AdminProfile::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'position' => $request->position,
|
|
||||||
'department' => $request->department,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 創建 API token
|
|
||||||
$token = $user->createToken('auth_token')->plainTextToken;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'status' => true,
|
|
||||||
'message' => '管理員註冊成功',
|
|
||||||
'data' => [
|
|
||||||
'user' => $user,
|
|
||||||
'token' => $token,
|
|
||||||
'token_type' => 'Bearer',
|
|
||||||
]
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理員登入
|
* 管理員登入
|
||||||
*/
|
*/
|
||||||
@@ -1051,6 +1060,63 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 會員 Token Refresh
|
||||||
|
*/
|
||||||
|
public function refreshMember(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->role !== self::ROLE_MEMBER) {
|
||||||
|
return $this->getUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服務提供者 Token Refresh
|
||||||
|
*/
|
||||||
|
public function refreshProvider(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->role !== self::ROLE_PROVIDER) {
|
||||||
|
return $this->getUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理員 Token Refresh
|
||||||
|
*/
|
||||||
|
public function refreshAdmin(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->role !== self::ROLE_ADMIN) {
|
||||||
|
return $this->getUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查詢會員資料
|
* 查詢會員資料
|
||||||
* 只有管理員可以使用這個方法
|
* 只有管理員可以使用這個方法
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Events\MessageRead;
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Events\NotificationCreated;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\BookingMessage;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\NewBookingMessageNotification;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
|
||||||
|
class BookingMessageController extends Controller
|
||||||
|
{
|
||||||
|
private function authorizeParticipant(Request $request, Booking $booking): bool
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$booking->loadMissing('schedule');
|
||||||
|
|
||||||
|
if ($user->role === 'member') {
|
||||||
|
return $booking->member_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->role === 'provider') {
|
||||||
|
return $booking->schedule->provider_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unreadCounts(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||||
|
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||||||
|
|
||||||
|
if ($senderType === 'member') {
|
||||||
|
$bookingIds = Booking::where('member_id', $user->id)
|
||||||
|
->whereIn('status', ['confirmed', 'completed'])
|
||||||
|
->pluck('id');
|
||||||
|
} else {
|
||||||
|
$bookingIds = Booking::whereHas('schedule', fn($q) => $q->where('provider_id', $user->id))
|
||||||
|
->whereIn('status', ['confirmed', 'completed'])
|
||||||
|
->pluck('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次 query 取得所有 booking 的未讀數(只計對方發的、且 read_at 為 NULL)
|
||||||
|
$counts = BookingMessage::whereIn('booking_id', $bookingIds)
|
||||||
|
->where('sender_type', $otherType)
|
||||||
|
->whereNull('read_at')
|
||||||
|
->selectRaw('booking_id, COUNT(*) as count')
|
||||||
|
->groupBy('booking_id')
|
||||||
|
->pluck('count', 'booking_id');
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $counts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, Booking $booking): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->authorizeParticipant($request, $booking)) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $booking->status->value;
|
||||||
|
if (!in_array($status, ['confirmed', 'completed'])) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $booking->messages()->orderBy('created_at')->get();
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $messages]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Booking $booking): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->authorizeParticipant($request, $booking)) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($booking->status->value !== 'confirmed') {
|
||||||
|
return response()->json(['status' => false, 'message' => '訊息功能僅在預約確認期間開放'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate(['type' => 'required|in:text,image']);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||||
|
|
||||||
|
if ($request->input('type') === 'text') {
|
||||||
|
$request->validate(['content' => 'required|string|max:5000']);
|
||||||
|
|
||||||
|
$message = BookingMessage::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'sender_id' => $user->id,
|
||||||
|
'sender_type' => $senderType,
|
||||||
|
'type' => 'text',
|
||||||
|
'content' => $request->input('content'),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$request->validate([
|
||||||
|
'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$manager = new ImageManager(new Driver());
|
||||||
|
$image = $manager->read($request->file('file'));
|
||||||
|
|
||||||
|
if ($image->width() > 2048 || $image->height() > 2048) {
|
||||||
|
$image->scaleDown(width: 2048, height: 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = Str::uuid();
|
||||||
|
$filename = "booking-images/{$uuid}.jpg";
|
||||||
|
$encoded = $image->toJpeg(quality: 85);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($filename, $encoded);
|
||||||
|
$url = Storage::disk('public')->url($filename);
|
||||||
|
|
||||||
|
$message = BookingMessage::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'sender_id' => $user->id,
|
||||||
|
'sender_type' => $senderType,
|
||||||
|
'type' => 'image',
|
||||||
|
'content' => $url,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(new MessageSent($message));
|
||||||
|
|
||||||
|
// 通知另一方(寫入 DB notification,讓 Bell 圖示更新)
|
||||||
|
$receiverId = $senderType === 'member'
|
||||||
|
? $booking->schedule->provider_id
|
||||||
|
: $booking->member_id;
|
||||||
|
|
||||||
|
$receiver = User::find($receiverId);
|
||||||
|
if ($receiver) {
|
||||||
|
// 同步寫進 DB(非 queue),確保下一行 broadcast 時 unread count 已是最新
|
||||||
|
$receiver->notify(new NewBookingMessageNotification($message, $booking, $user));
|
||||||
|
// 通知前端:立刻更新 bell badge,不等 polling
|
||||||
|
broadcast(new NotificationCreated($receiver->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $message], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markRead(Request $request, Booking $booking): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->authorizeParticipant($request, $booking)) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $booking->status->value;
|
||||||
|
if (!in_array($status, ['confirmed', 'completed'])) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate(['last_read_message_id' => 'required|integer']);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||||
|
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||||||
|
|
||||||
|
$updated = BookingMessage::where('booking_id', $booking->id)
|
||||||
|
->where('sender_type', $otherType)
|
||||||
|
->where('id', '<=', $request->input('last_read_message_id'))
|
||||||
|
->whereNull('read_at')
|
||||||
|
->update(['read_at' => now()]);
|
||||||
|
|
||||||
|
if ($updated > 0 && $status === 'confirmed') {
|
||||||
|
broadcast(new MessageRead(
|
||||||
|
$booking->id,
|
||||||
|
$senderType,
|
||||||
|
$request->input('last_read_message_id'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,19 @@ namespace App\Http\Controllers\API;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\CourseImage;
|
use App\Models\CourseImage;
|
||||||
use App\Models\DivingOffer;
|
use App\Models\DivingOffer;
|
||||||
|
use App\Traits\CompressesImages;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class CourseImageController extends Controller
|
class CourseImageController extends Controller
|
||||||
{
|
{
|
||||||
|
use CompressesImages;
|
||||||
|
|
||||||
private function validateImage(Request $request): void
|
private function validateImage(Request $request): void
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
|
// 10MB:伺服器端會壓縮(compressToJpeg),放寬以容納手機原圖
|
||||||
|
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ class CourseImageController extends Controller
|
|||||||
Storage::disk('public')->delete($offer->cover_image);
|
Storage::disk('public')->delete($offer->cover_image);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
|
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/cover");
|
||||||
$offer->update(['cover_image' => $path]);
|
$offer->update(['cover_image' => $path]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -67,7 +71,7 @@ class CourseImageController extends Controller
|
|||||||
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
|
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
|
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/gallery");
|
||||||
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
|
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
|
||||||
|
|
||||||
$image = CourseImage::create([
|
$image = CourseImage::create([
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class DivingOfferController extends Controller
|
|||||||
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
|
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
|
||||||
|
|
||||||
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
||||||
$query = DivingOffer::query();
|
$query = DivingOffer::query()->visibleToPublic();
|
||||||
|
|
||||||
if ($q = $request->query('q')) {
|
if ($q = $request->query('q')) {
|
||||||
$query->where(function ($sub) use ($q) {
|
$query->where(function ($sub) use ($q) {
|
||||||
@@ -55,7 +55,7 @@ class DivingOfferController extends Controller
|
|||||||
|
|
||||||
public function show(int $id)
|
public function show(int $id)
|
||||||
{
|
{
|
||||||
$offer = DivingOffer::with('courseImages')->find($id);
|
$offer = DivingOffer::with('courseImages')->visibleToPublic()->find($id);
|
||||||
|
|
||||||
if (!$offer) {
|
if (!$offer) {
|
||||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||||
|
|||||||
@@ -45,9 +45,15 @@ class MemberBookingController extends Controller
|
|||||||
'notes' => 'nullable|string|max:500',
|
'notes' => 'nullable|string|max:500',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
|
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
|
||||||
|
|
||||||
// Layer 1:快速失敗
|
// Layer 1:快速失敗
|
||||||
|
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
|
||||||
|
$offer = $schedule->divingOffer;
|
||||||
|
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
|
||||||
|
// is_verified 為 accessor(= verification_status === approved)
|
||||||
|
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
|
||||||
|
}
|
||||||
if ($schedule->status !== ScheduleStatus::Open) {
|
if ($schedule->status !== ScheduleStatus::Open) {
|
||||||
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Enums\VerificationStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ProviderCertification;
|
||||||
|
use App\Traits\CompressesImages;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ProviderVerificationController extends Controller
|
||||||
|
{
|
||||||
|
use CompressesImages;
|
||||||
|
|
||||||
|
private const MAX_CERTIFICATIONS = 3;
|
||||||
|
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$profile = $request->user()->providerProfile;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => [
|
||||||
|
'verification_status' => $profile->verification_status->value,
|
||||||
|
'rejection_reason' => $profile->rejection_reason,
|
||||||
|
'certifications' => $request->user()->providerCertifications
|
||||||
|
->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadCertification(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($err = $this->ensureCertificationsEditable($request)) {
|
||||||
|
return $err;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) {
|
||||||
|
return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications");
|
||||||
|
|
||||||
|
$certification = ProviderCertification::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'image_path' => $path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'message' => '證照已上傳',
|
||||||
|
'data' => ['id' => $certification->id, 'url' => $certification->url],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCertification(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$certification = ProviderCertification::findOrFail($id);
|
||||||
|
|
||||||
|
if ($certification->user_id !== $request->user()->id) {
|
||||||
|
return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($err = $this->ensureCertificationsEditable($request)) {
|
||||||
|
return $err;
|
||||||
|
}
|
||||||
|
|
||||||
|
$certification->delete();
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'message' => '證照已刪除']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$profile = $user->providerProfile;
|
||||||
|
|
||||||
|
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) {
|
||||||
|
return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->providerCertifications()->count() < 1) {
|
||||||
|
return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile->update([
|
||||||
|
'verification_status' => VerificationStatus::Pending,
|
||||||
|
'rejection_reason' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動
|
||||||
|
*/
|
||||||
|
private function ensureCertificationsEditable(Request $request)
|
||||||
|
{
|
||||||
|
$status = $request->user()->providerProfile->verification_status;
|
||||||
|
|
||||||
|
if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) {
|
||||||
|
return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ class ReviewController extends Controller
|
|||||||
|
|
||||||
public function publicList(Request $request, int $offerId)
|
public function publicList(Request $request, int $offerId)
|
||||||
{
|
{
|
||||||
$offer = DivingOffer::findOrFail($offerId);
|
$offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$perPage = min((int) $request->query('per_page', 20), 50);
|
$perPage = min((int) $request->query('per_page', 20), 50);
|
||||||
$sort = $request->query('sort', 'helpful');
|
$sort = $request->query('sort', 'helpful');
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class ScheduleController extends Controller
|
|||||||
|
|
||||||
public function publicList(int $offerId)
|
public function publicList(int $offerId)
|
||||||
{
|
{
|
||||||
$offer = DivingOffer::findOrFail($offerId);
|
$offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
|
||||||
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
|
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
|
||||||
->where('status', ScheduleStatus::Open->value)
|
->where('status', ScheduleStatus::Open->value)
|
||||||
->whereDate('scheduled_date', '>=', now()->toDateString())
|
->whereDate('scheduled_date', '>=', now()->toDateString())
|
||||||
|
|||||||
@@ -18,11 +18,19 @@ class SocialAuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function redirectToGoogle()
|
public function redirectToGoogle()
|
||||||
{
|
{
|
||||||
return Socialite::driver('google')
|
$state = bin2hex(random_bytes(32));
|
||||||
|
session()->put('oauth_state', $state);
|
||||||
|
|
||||||
|
$redirect = Socialite::driver('google')
|
||||||
->scopes(['openid', 'profile', 'email'])
|
->scopes(['openid', 'profile', 'email'])
|
||||||
->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token
|
->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state])
|
||||||
->stateless()
|
|
||||||
->redirect();
|
->redirect();
|
||||||
|
|
||||||
|
// Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。
|
||||||
|
// 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。
|
||||||
|
session()->put('state', $state);
|
||||||
|
|
||||||
|
return $redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +38,16 @@ class SocialAuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function handleGoogleCallback(Request $request)
|
public function handleGoogleCallback(Request $request)
|
||||||
{
|
{
|
||||||
|
$requestState = $request->input('state');
|
||||||
|
$sessionState = session()->pull('oauth_state');
|
||||||
|
|
||||||
|
if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) {
|
||||||
|
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 獲取 Google 用戶資訊
|
// 獲取 Google 用戶資訊(Socialite 內建 state 驗證此時也會通過)
|
||||||
$googleUser = Socialite::driver('google')->stateless()->user();
|
$googleUser = Socialite::driver('google')->user();
|
||||||
|
|
||||||
// 查找相關的社交帳號
|
// 查找相關的社交帳號
|
||||||
$socialAccount = SocialAccount::where('provider', 'google')
|
$socialAccount = SocialAccount::where('provider', 'google')
|
||||||
@@ -106,9 +121,9 @@ class SocialAuthController extends Controller
|
|||||||
// 生成 Sanctum token
|
// 生成 Sanctum token
|
||||||
$token = $user->createToken('google-auth')->plainTextToken;
|
$token = $user->createToken('google-auth')->plainTextToken;
|
||||||
|
|
||||||
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
|
return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
|
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,9 @@ class Booking extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'member_id');
|
return $this->belongsTo(User::class, 'member_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function messages()
|
||||||
|
{
|
||||||
|
return $this->hasMany(BookingMessage::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BookingMessage extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'booking_id',
|
||||||
|
'sender_id',
|
||||||
|
'sender_type',
|
||||||
|
'type',
|
||||||
|
'content',
|
||||||
|
'read_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function booking()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Booking::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sender()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'sender_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -53,6 +54,18 @@ class DivingOffer extends Model
|
|||||||
return $this->belongsTo(User::class, 'provider_id');
|
return $this->belongsTo(User::class, 'provider_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。
|
||||||
|
* provider_id 為 null 的課程(平台自有資料)不受此限制。
|
||||||
|
*/
|
||||||
|
public function scopeVisibleToPublic(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $visible) {
|
||||||
|
$visible->whereNull('provider_id')
|
||||||
|
->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function schedules()
|
public function schedules()
|
||||||
{
|
{
|
||||||
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ProviderCertification extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'image_path',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::deleting(function ($certification) {
|
||||||
|
Storage::disk('public')->delete($certification->image_path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUrlAttribute(): string
|
||||||
|
{
|
||||||
|
return Storage::disk('public')->url($this->image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\VerificationStatus;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,9 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"),
|
* @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"),
|
||||||
* @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"),
|
* @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"),
|
||||||
* @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"),
|
* @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"),
|
||||||
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證"),
|
* @OA\Property(property="verification_status", type="string", example="approved", description="審核狀態:unsubmitted / pending / approved / rejected"),
|
||||||
|
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null, description="駁回原因(rejected 時有值)"),
|
||||||
|
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證(相容欄位,= verification_status 為 approved)"),
|
||||||
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
|
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
|
||||||
* @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"),
|
* @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"),
|
||||||
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
|
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
|
||||||
@@ -55,7 +58,8 @@ class ProviderProfile extends Model
|
|||||||
'certifications',
|
'certifications',
|
||||||
'facilities',
|
'facilities',
|
||||||
'business_hours',
|
'business_hours',
|
||||||
'is_verified',
|
'verification_status',
|
||||||
|
'rejection_reason',
|
||||||
'rating',
|
'rating',
|
||||||
'website',
|
'website',
|
||||||
'social_media',
|
'social_media',
|
||||||
@@ -64,6 +68,21 @@ class ProviderProfile extends Model
|
|||||||
'is_active'
|
'is_active'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'verification_status' => VerificationStatus::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 相容層:既有前端與 Swagger 讀取 is_verified boolean,
|
||||||
|
* 欄位移除後以 accessor 維持輸出(= 審核通過)
|
||||||
|
*/
|
||||||
|
protected $appends = ['is_verified'];
|
||||||
|
|
||||||
|
public function getIsVerifiedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->verification_status === VerificationStatus::Approved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 與用戶的關聯
|
* 與用戶的關聯
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ class User extends Authenticatable
|
|||||||
return $this->hasOne(ProviderProfile::class);
|
return $this->hasOne(ProviderProfile::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教練驗證證照圖片(送審用)
|
||||||
|
*/
|
||||||
|
public function providerCertifications()
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProviderCertification::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 獲取用戶的會員資料
|
* 獲取用戶的會員資料
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\BookingMessage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
// 不走 Queue:通知需要在 HTTP response 前同步寫進 DB,
|
||||||
|
// 讓後續的 NotificationCreated broadcast 能立刻讓前端拉到正確的 unread count。
|
||||||
|
class NewBookingMessageNotification extends Notification
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly BookingMessage $message,
|
||||||
|
public readonly Booking $booking,
|
||||||
|
public readonly User $sender,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
// 聊天訊息通知只寫進 DB,不寄信(太頻繁)
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
// 顯示名稱:教練加前綴,學員直接用名字
|
||||||
|
$senderLabel = $this->sender->role === 'provider'
|
||||||
|
? "教練 {$this->sender->name}"
|
||||||
|
: $this->sender->name;
|
||||||
|
|
||||||
|
$preview = $this->message->type === 'image'
|
||||||
|
? '傳送了一張圖片'
|
||||||
|
: mb_strimwidth($this->message->content, 0, 50, '…');
|
||||||
|
|
||||||
|
// 依收件方角色決定跳轉路徑
|
||||||
|
$actionUrl = $notifiable->role === 'provider'
|
||||||
|
? config('app.frontend_url') . '/coach/bookings'
|
||||||
|
: config('app.frontend_url') . '/my-bookings';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'new_message',
|
||||||
|
'title' => "{$senderLabel} 傳來新訊息",
|
||||||
|
'body' => $preview,
|
||||||
|
'action_url' => $actionUrl,
|
||||||
|
'related_id' => $this->booking->id,
|
||||||
|
'related_type' => 'booking',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ProviderVerificationApprovedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database', 'mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'verification_approved',
|
||||||
|
'title' => '審核通過',
|
||||||
|
'body' => '恭喜!你的教練資格已通過平台審核,課程現在會公開曝光並可接受預約。',
|
||||||
|
'action_url' => config('app.frontend_url') . '/coach/dashboard',
|
||||||
|
'related_id' => $notifiable->id,
|
||||||
|
'related_type' => 'provider_verification',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('教練資格審核通過 — CFDivePlatform')
|
||||||
|
->greeting('恭喜!')
|
||||||
|
->line('你的教練資格已通過平台審核。')
|
||||||
|
->line('你的課程現在會公開曝光,並可開始接受會員預約。')
|
||||||
|
->action('前往教練後台', config('app.frontend_url') . '/coach/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ProviderVerificationRejectedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public function __construct(public readonly string $reason) {}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database', 'mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'verification_rejected',
|
||||||
|
'title' => '審核未通過',
|
||||||
|
'body' => "你的教練資格審核未通過。原因:{$this->reason}",
|
||||||
|
'action_url' => config('app.frontend_url') . '/coach/verification',
|
||||||
|
'related_id' => $notifiable->id,
|
||||||
|
'related_type' => 'provider_verification',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('教練資格審核結果 — CFDivePlatform')
|
||||||
|
->greeting('審核結果通知')
|
||||||
|
->line('很抱歉,你的教練資格審核未通過。')
|
||||||
|
->line("原因:{$this->reason}")
|
||||||
|
->line('你可以補正資料與證照後重新送審。')
|
||||||
|
->action('重新送審', config('app.frontend_url') . '/coach/verification');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
|
||||||
|
trait CompressesImages
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 壓縮上傳圖片並存入 public disk,回傳相對路徑。
|
||||||
|
* 參數與聊天圖片管線一致(scaleDown 2048 + JPEG quality 85),
|
||||||
|
* 確保平台內影像處理行為單一來源。
|
||||||
|
*/
|
||||||
|
private function compressToJpeg(UploadedFile $file, string $directory): string
|
||||||
|
{
|
||||||
|
$manager = new ImageManager(new Driver());
|
||||||
|
$image = $manager->read($file);
|
||||||
|
|
||||||
|
if ($image->width() > 2048 || $image->height() > 2048) {
|
||||||
|
$image->scaleDown(width: 2048, height: 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim($directory, '/') . '/' . Str::uuid() . '.jpg';
|
||||||
|
|
||||||
|
Storage::disk('public')->put($path, $image->toJpeg(quality: 85));
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
trait NormalizesEmail
|
||||||
|
{
|
||||||
|
private function normalizeEmail(string $email): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($email));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -9,13 +9,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
$middleware->trustProxies(at: ['*']);
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
\Sentry\Laravel\Integration::handles($exceptions);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin
|
||||||
|
container_name: cfdive-phpmyadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8081:80"
|
||||||
|
environment:
|
||||||
|
PMA_HOST: db
|
||||||
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: ${DB_USERNAME:-cfdiveuser}
|
||||||
|
PMA_PASSWORD: ${DB_PASSWORD}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit
|
||||||
|
container_name: cfdive-mailpit
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8025:8025"
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
+4
-1
@@ -7,11 +7,14 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"darkaonline/l5-swagger": "^9.0",
|
"darkaonline/l5-swagger": "^9.0",
|
||||||
|
"intervention/image": "^3.11",
|
||||||
"laravel/framework": "^11.0",
|
"laravel/framework": "^11.0",
|
||||||
|
"laravel/reverb": "^1.10",
|
||||||
"laravel/sanctum": "^4.1",
|
"laravel/sanctum": "^4.1",
|
||||||
"laravel/socialite": "^5.20",
|
"laravel/socialite": "^5.20",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
"predis/predis": "^3.4"
|
"predis/predis": "^3.4",
|
||||||
|
"sentry/sentry-laravel": "^4.25"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+1534
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
|
||||||
|
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
|
||||||
|
];
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Broadcaster
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default broadcaster that will be used by the
|
||||||
|
| framework when an event needs to be broadcast. You may set this to
|
||||||
|
| any of the connections defined in the "connections" array below.
|
||||||
|
|
|
||||||
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Broadcast Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the broadcast connections that will be used
|
||||||
|
| to broadcast events to other systems or over WebSockets. Samples of
|
||||||
|
| each available type of connection are provided inside this array.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'driver' => 'reverb',
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pusher' => [
|
||||||
|
'driver' => 'pusher',
|
||||||
|
'key' => env('PUSHER_APP_KEY'),
|
||||||
|
'secret' => env('PUSHER_APP_SECRET'),
|
||||||
|
'app_id' => env('PUSHER_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||||
|
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||||
|
'port' => env('PUSHER_PORT', 443),
|
||||||
|
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||||
|
'encrypted' => true,
|
||||||
|
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'ably' => [
|
||||||
|
'driver' => 'ably',
|
||||||
|
'key' => env('ABLY_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'driver' => 'log',
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'null',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -5,7 +5,7 @@ return [
|
|||||||
'documentations' => [
|
'documentations' => [
|
||||||
'default' => [
|
'default' => [
|
||||||
'api' => [
|
'api' => [
|
||||||
'title' => 'L5 Swagger UI',
|
'title' => 'CFDive Platform API',
|
||||||
],
|
],
|
||||||
|
|
||||||
'routes' => [
|
'routes' => [
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Reverb Server
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default server used by Reverb to handle
|
||||||
|
| incoming messages as well as broadcasting message to all your
|
||||||
|
| connected clients. At this time only "reverb" is supported.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('REVERB_SERVER', 'reverb'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Servers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define details for each of the supported Reverb servers.
|
||||||
|
| Each server has its own configuration options that are defined in
|
||||||
|
| the array below. You should ensure all the options are present.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'servers' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||||
|
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||||
|
'path' => env('REVERB_SERVER_PATH', ''),
|
||||||
|
'hostname' => env('REVERB_SERVER_HOSTNAME', null),
|
||||||
|
'options' => [
|
||||||
|
'tls' => [],
|
||||||
|
],
|
||||||
|
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||||
|
'scaling' => [
|
||||||
|
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||||
|
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||||
|
'server' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||||
|
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Applications
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define how Reverb applications are managed. If you choose
|
||||||
|
| to use the "config" provider, you may define an array of apps which
|
||||||
|
| your server will support, including their connection credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
|
||||||
|
'provider' => 'config',
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
[
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'allowed_origins' => ['*'],
|
||||||
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
|
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||||
|
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||||
|
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||||
|
'rate_limiting' => [
|
||||||
|
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||||
|
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||||
|
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||||
|
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+1
-1
@@ -47,7 +47,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'expiration' => null,
|
'expiration' => 60 * 24 * 7,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentry Laravel SDK configuration file.
|
||||||
|
*
|
||||||
|
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
||||||
|
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
|
||||||
|
|
||||||
|
// @see https://spotlightjs.com/
|
||||||
|
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
|
||||||
|
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
|
||||||
|
|
||||||
|
// The release version of your application
|
||||||
|
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
|
||||||
|
'release' => env('SENTRY_RELEASE'),
|
||||||
|
|
||||||
|
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
|
||||||
|
'environment' => env('SENTRY_ENVIRONMENT'),
|
||||||
|
|
||||||
|
// Override the organization ID used for trace continuation checks.
|
||||||
|
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
|
||||||
|
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
|
||||||
|
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
|
||||||
|
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
|
||||||
|
|
||||||
|
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
|
||||||
|
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
|
||||||
|
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
|
||||||
|
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
|
||||||
|
|
||||||
|
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
|
||||||
|
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
|
||||||
|
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
|
||||||
|
// 'ignore_exceptions' => [],
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
|
||||||
|
'ignore_transactions' => [
|
||||||
|
// Ignore Laravel's default health URL
|
||||||
|
'/up',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Breadcrumb specific configuration
|
||||||
|
'breadcrumbs' => [
|
||||||
|
// Capture Laravel logs as breadcrumbs
|
||||||
|
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
|
||||||
|
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Livewire components like routes as breadcrumbs
|
||||||
|
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL queries as breadcrumbs
|
||||||
|
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
|
||||||
|
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
|
||||||
|
|
||||||
|
// Capture queue job information as breadcrumbs
|
||||||
|
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture command information as breadcrumbs
|
||||||
|
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture HTTP client request information as breadcrumbs
|
||||||
|
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture send notifications as breadcrumbs
|
||||||
|
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Performance monitoring specific configuration
|
||||||
|
'tracing' => [
|
||||||
|
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
|
||||||
|
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture queue jobs as spans when executed on the sync driver
|
||||||
|
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL queries as spans
|
||||||
|
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL query bindings (parameters) in SQL query spans
|
||||||
|
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
|
||||||
|
|
||||||
|
// Capture where the SQL query originated from on the SQL query spans
|
||||||
|
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
|
||||||
|
|
||||||
|
// Define a threshold in milliseconds for SQL queries to resolve their origin
|
||||||
|
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
|
||||||
|
|
||||||
|
// Capture views rendered as spans
|
||||||
|
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Livewire components as spans
|
||||||
|
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture HTTP client requests as spans
|
||||||
|
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Laravel cache events (hits, writes etc.) as spans
|
||||||
|
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Redis operations as spans (this enables Redis events in Laravel)
|
||||||
|
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
|
||||||
|
|
||||||
|
// Capture where the Redis command originated from on the Redis command spans
|
||||||
|
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture send notifications as spans
|
||||||
|
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
|
||||||
|
|
||||||
|
// Enable tracing for requests without a matching route (404's)
|
||||||
|
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
|
||||||
|
|
||||||
|
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
|
||||||
|
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
|
||||||
|
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
|
||||||
|
|
||||||
|
// Enable the tracing integrations supplied by Sentry (recommended)
|
||||||
|
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -19,7 +19,7 @@ return new class extends Migration
|
|||||||
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
|
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
|
||||||
$table->string('password'); // 密碼
|
$table->string('password'); // 密碼
|
||||||
$table->string('phone')->nullable(); // 電話號碼,可為空
|
$table->string('phone')->nullable(); // 電話號碼,可為空
|
||||||
$table->enum('role', ['admin', 'coach', 'member'])->default('member'); // 角色:管理員、教練、會員
|
$table->enum('role', ['admin', 'coach', 'member', 'provider'])->default('member'); // 角色:管理員、教練、會員、服務提供者
|
||||||
$table->boolean('is_active')->default(true); // 是否啟用
|
$table->boolean('is_active')->default(true); // 是否啟用
|
||||||
$table->rememberToken(); // 記住我 token
|
$table->rememberToken(); // 記住我 token
|
||||||
$table->timestamps(); // 建立與更新時間
|
$table->timestamps(); // 建立與更新時間
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
|
if (DB::getDriverName() === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
|
if (DB::getDriverName() === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?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::create("booking_messages", function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId("booking_id")->constrained("bookings")->cascadeOnDelete();
|
||||||
|
$table->foreignId("sender_id")->constrained("users")->cascadeOnDelete();
|
||||||
|
$table->enum("sender_type", ["member", "provider"]);
|
||||||
|
$table->enum("type", ["text", "image"]);
|
||||||
|
$table->text("content");
|
||||||
|
$table->timestamp("read_at")->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(["booking_id", "created_at"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists("booking_messages");
|
||||||
|
}
|
||||||
|
};
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('provider_profiles', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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::create('provider_certifications', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -65,7 +65,7 @@ class DemoSeeder extends Seeder
|
|||||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
|
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
|
||||||
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
|
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
|
||||||
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
|
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
|
||||||
'business_hours' => '每日 07:30–18:00', 'is_verified' => true,
|
'business_hours' => '每日 07:30–18:00', 'verification_status' => 'approved',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
|
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
|
||||||
@@ -77,7 +77,7 @@ class DemoSeeder extends Seeder
|
|||||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
|
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
|
||||||
'certifications' => 'PADI 潛水中心,SSI 認證教練',
|
'certifications' => 'PADI 潛水中心,SSI 認證教練',
|
||||||
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
|
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
|
||||||
'business_hours' => '每日 07:00–17:30', 'is_verified' => true,
|
'business_hours' => '每日 07:00–17:30', 'verification_status' => 'approved',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'email' => 'islet@cfdive.com', 'name' => '張大偉',
|
'email' => 'islet@cfdive.com', 'name' => '張大偉',
|
||||||
@@ -89,7 +89,7 @@ class DemoSeeder extends Seeder
|
|||||||
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
|
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
|
||||||
'certifications' => 'PADI 授權潛水中心',
|
'certifications' => 'PADI 授權潛水中心',
|
||||||
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
|
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
|
||||||
'business_hours' => '每日 08:00–17:00', 'is_verified' => true,
|
'business_hours' => '每日 08:00–17:00', 'verification_status' => 'approved',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'email' => 'northdive@cfdive.com', 'name' => '王建國',
|
'email' => 'northdive@cfdive.com', 'name' => '王建國',
|
||||||
@@ -101,7 +101,7 @@ class DemoSeeder extends Seeder
|
|||||||
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
|
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
|
||||||
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
|
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
|
||||||
'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修',
|
'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'],
|
'certifications' => $data['certifications'],
|
||||||
'facilities' => $data['facilities'],
|
'facilities' => $data['facilities'],
|
||||||
'business_hours' => $data['business_hours'],
|
'business_hours' => $data['business_hours'],
|
||||||
'is_verified' => $data['is_verified'],
|
'verification_status' => $data['verification_status'],
|
||||||
'rating' => 0,
|
'rating' => 0,
|
||||||
]);
|
]);
|
||||||
$providers[] = $user;
|
$providers[] = $user;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class DevelopmentSeeder extends Seeder
|
|||||||
'description' => '專業 PADI 認證教練,10 年教學經驗',
|
'description' => '專業 PADI 認證教練,10 年教學經驗',
|
||||||
'contact_phone' => '0912345678',
|
'contact_phone' => '0912345678',
|
||||||
'contact_email' => 'coach@cfdive.com',
|
'contact_email' => 'coach@cfdive.com',
|
||||||
'is_verified' => true,
|
'verification_status' => 'approved',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Member
|
// Member
|
||||||
|
|||||||
+68
-22
@@ -38,16 +38,18 @@ services:
|
|||||||
|
|
||||||
# 健康檢查配置
|
# 健康檢查配置
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
|
test: ["CMD-SHELL", "nc -z 127.0.0.1 9000 || exit 1"]
|
||||||
interval: 30s # 每30秒檢查一次
|
interval: 30s
|
||||||
timeout: 10s # 超時時間10秒
|
timeout: 10s
|
||||||
retries: 3 # 重試3次
|
retries: 3
|
||||||
start_period: 40s # 容器啟動後40秒開始檢查
|
start_period: 40s
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: cfdive-nginx
|
container_name: cfdive-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www
|
- ./:/var/www
|
||||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||||
@@ -58,7 +60,8 @@ services:
|
|||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
# busybox wget 對 localhost 會先解析到 IPv6 ::1,但 nginx 只 listen IPv4,須用 127.0.0.1
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -68,10 +71,16 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
|
VITE_API_URL: ${VITE_API_URL:-https://api.hank-space.com}
|
||||||
|
VITE_REVERB_APP_KEY: ${VITE_REVERB_APP_KEY}
|
||||||
|
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||||
|
VITE_REVERB_PORT: ${VITE_REVERB_PORT:-8085}
|
||||||
|
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
|
||||||
image: cfdive-frontend
|
image: cfdive-frontend
|
||||||
container_name: cfdive-frontend
|
container_name: cfdive-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5173:80"
|
||||||
networks:
|
networks:
|
||||||
- cfdive-network
|
- cfdive-network
|
||||||
- proxy_net
|
- proxy_net
|
||||||
@@ -97,21 +106,29 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3306:3306" # 只綁 localhost,不對外
|
||||||
|
|
||||||
phpmyadmin:
|
|
||||||
image: phpmyadmin/phpmyadmin
|
scheduler:
|
||||||
container_name: cfdive-phpmyadmin
|
image: cfdive-platform
|
||||||
|
container_name: cfdive-scheduler
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
working_dir: /var/www/
|
||||||
|
command: php artisan schedule:work
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
- DB_CONNECTION=mysql
|
||||||
PMA_PORT: 3306
|
- DB_HOST=db
|
||||||
PMA_USER: ${DB_USERNAME:-cfdiveuser}
|
- DB_PORT=3306
|
||||||
PMA_PASSWORD: ${DB_PASSWORD}
|
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
|
||||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
networks:
|
networks:
|
||||||
- cfdive-network
|
- cfdive-network
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
queue-worker:
|
queue-worker:
|
||||||
@@ -135,12 +152,6 @@ services:
|
|||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
mailpit:
|
|
||||||
image: axllent/mailpit
|
|
||||||
container_name: cfdive-mailpit
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- cfdive-network
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
@@ -148,12 +159,45 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- cfdive-network
|
- cfdive-network
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
reverb:
|
||||||
|
image: cfdive-platform
|
||||||
|
container_name: cfdive-reverb
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /var/www/
|
||||||
|
entrypoint: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8085:8080"
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
|
environment:
|
||||||
|
- APP_KEY=${APP_KEY}
|
||||||
|
- REVERB_APP_ID=${REVERB_APP_ID}
|
||||||
|
- REVERB_APP_KEY=${REVERB_APP_KEY}
|
||||||
|
- REVERB_APP_SECRET=${REVERB_APP_SECRET}
|
||||||
|
- REVERB_HOST=reverb
|
||||||
|
- REVERB_PORT=8080
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- DB_CONNECTION=mysql
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
|
||||||
|
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
- proxy_net
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cfdive-network:
|
cfdive-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -163,3 +207,5 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
redis-data:
|
||||||
|
driver: local
|
||||||
|
|||||||
+44
-103
@@ -3,120 +3,61 @@ set -e
|
|||||||
|
|
||||||
echo "=== CFDivePlatform 容器初始化開始 ==="
|
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||||
|
|
||||||
# 檢查目錄結構
|
# 確保目錄與權限(php-fpm 啟動前必須完成)
|
||||||
if [ ! -d "/var/www/storage" ]; then
|
[ ! -d "/var/www/storage" ] && mkdir -p /var/www/storage
|
||||||
echo "創建 storage 目錄..."
|
[ ! -d "/var/www/bootstrap/cache" ] && mkdir -p /var/www/bootstrap/cache
|
||||||
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
|
chown -R www-data:www-data /var/www
|
||||||
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||||
|
|
||||||
# 等待 MySQL 服務啟動
|
# 確保 .env 存在
|
||||||
echo "等待 MySQL 服務啟動..."
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
fi
|
||||||
|
|
||||||
# 使用更穩定的方法檢查 MySQL 連接
|
# 強制 DB_HOST=db(Docker service name,不能用 localhost)
|
||||||
MAX_TRIES=60
|
php -r "
|
||||||
COUNT=0
|
\$env = file_get_contents('/var/www/.env');
|
||||||
|
\$env = preg_replace('/^DB_HOST=.*$/m', 'DB_HOST=db', \$env);
|
||||||
|
file_put_contents('/var/www/.env', \$env);
|
||||||
|
"
|
||||||
|
|
||||||
wait_for_mysql() {
|
# Composer 依賴(vendor 不存在或 lock 內容變更時才裝)
|
||||||
while [ $COUNT -lt $MAX_TRIES ]; do
|
# 用內容比對而非 mtime:git checkout/pull 會更新 composer.json 的 mtime,
|
||||||
if mysqladmin ping -h"db" -u"cfdiveuser" -p"**REMOVED**" --silent 2>/dev/null; then
|
# mtime 比對會讓每次分支操作後的開機都重跑 autoload 生成(bind mount 上耗時數分鐘、期間全站 502)
|
||||||
echo "✅ MySQL 服務已準備就緒"
|
if [ -f "composer.lock" ] && { [ ! -d "vendor" ] || ! cmp -s composer.lock vendor/.composer.lock.installed; }; then
|
||||||
return 0
|
composer install --no-scripts --optimize-autoloader
|
||||||
fi
|
cp composer.lock vendor/.composer.lock.installed
|
||||||
|
fi
|
||||||
|
|
||||||
# 備用檢查方法
|
# 背景執行:等 MySQL → migration → cache clear → storage link → swagger
|
||||||
if php -r "
|
# php-fpm 不等這些完成就先啟動,避免重啟時 CORS 502
|
||||||
try {
|
(
|
||||||
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', '**REMOVED**');
|
echo "⏳ [背景] 等待 MySQL..."
|
||||||
echo 'PHP-PDO-OK';
|
COUNT=0
|
||||||
exit(0);
|
until mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null || [ $COUNT -ge 30 ]; do
|
||||||
} catch(Exception \$e) {
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
" 2>/dev/null; then
|
|
||||||
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
|
|
||||||
sleep 2
|
sleep 2
|
||||||
COUNT=$((COUNT+1))
|
COUNT=$((COUNT+1))
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ $COUNT -eq $MAX_TRIES ]; then
|
echo "🗄️ [背景] 執行 migration..."
|
||||||
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
php artisan migrate --force || echo "⚠️ migration 失敗"
|
||||||
|
|
||||||
|
echo "🧹 [背景] 清除 Laravel 緩存..."
|
||||||
|
php artisan config:clear || true
|
||||||
|
php artisan cache:clear || true
|
||||||
|
php artisan route:clear || true
|
||||||
|
php artisan view:clear || true
|
||||||
|
|
||||||
|
echo "🔗 [背景] storage:link..."
|
||||||
|
php artisan storage:link --force || true
|
||||||
|
|
||||||
|
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||||
|
php artisan l5-swagger:generate || true
|
||||||
fi
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_mysql
|
echo "✅ [背景] 初始化完成"
|
||||||
|
) &
|
||||||
|
|
||||||
# 檢查並安裝 Composer 依賴
|
echo "🚀 啟動 php-fpm..."
|
||||||
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=**REMOVED**/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 "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -3,3 +3,14 @@ post_max_size=40M
|
|||||||
memory_limit=512M
|
memory_limit=512M
|
||||||
max_execution_time=600
|
max_execution_time=600
|
||||||
max_input_vars=10000
|
max_input_vars=10000
|
||||||
|
|
||||||
|
; ── OPcache 調校(2026-06-11 效能優化 O1.1)──
|
||||||
|
; 預設 revalidate_freq=2 在 Windows bind mount 上每 2 秒就重新 stat 全部
|
||||||
|
; 已快取腳本(~850 檔),實測使每次閒置後的請求從 0.23s 暴增至 2.5s。
|
||||||
|
; 拉長至 30 秒:程式碼變更最多 30 秒後生效;需要立即生效時執行
|
||||||
|
; docker compose exec app kill -USR2 1(php-fpm 平滑重載,約 1 秒)
|
||||||
|
opcache.enable=1
|
||||||
|
opcache.memory_consumption=192
|
||||||
|
opcache.max_accelerated_files=20000
|
||||||
|
opcache.validate_timestamps=1
|
||||||
|
opcache.revalidate_freq=30
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
# CFDivePlatform 啟動指令
|
||||||
|
|
||||||
|
## 部署模式
|
||||||
|
|
||||||
|
專案根目錄有兩個 compose 檔:
|
||||||
|
|
||||||
|
| 檔案 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `docker-compose.yml` | 正式服務(app、nginx、frontend、db、redis、reverb、queue-worker、scheduler) |
|
||||||
|
| `compose.override.yml` | 開發工具(phpmyadmin、mailpit),已進版控 |
|
||||||
|
|
||||||
|
**本機 / VPS(預設)**:Docker Compose 自動合併兩檔,開發工具一同啟動:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**雲端正式環境**:明確指定單一檔案,排除開發工具:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> VPS 首次部署或更新後執行 `git pull` 即可取得最新的 `compose.override.yml`,不需手動建立。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 專案位置
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:\laragon\www\CFDivePlatform
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. 進入專案目錄
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\laragon\www\CFDivePlatform
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 準備 `.env`
|
||||||
|
|
||||||
|
如果尚未建立 `.env`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
確認 `.env` 至少設定以下項目:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_KEY=
|
||||||
|
DB_DATABASE=CFDivePlatform
|
||||||
|
DB_USERNAME=cfdiveuser
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
MYSQL_ROOT_PASSWORD=your_root_password
|
||||||
|
|
||||||
|
REVERB_APP_ID=your_reverb_app_id
|
||||||
|
REVERB_APP_KEY=your_reverb_app_key
|
||||||
|
REVERB_APP_SECRET=your_reverb_app_secret
|
||||||
|
VITE_REVERB_APP_KEY=your_reverb_app_key
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `APP_KEY` 是空的,可在容器啟動後執行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec app php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 啟動 Docker 服務
|
||||||
|
|
||||||
|
第一次啟動,或 Dockerfile / compose 設定有變更時:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
平常啟動:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 查看服務狀態
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
查看所有服務 log:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
只查看 Laravel App log:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 初始化與維護指令
|
||||||
|
|
||||||
|
容器啟動時會自動執行部分初始化流程:
|
||||||
|
|
||||||
|
- 安裝 Composer 依賴
|
||||||
|
- 等待 MySQL 啟動
|
||||||
|
- 執行 migration
|
||||||
|
- 清除 Laravel cache
|
||||||
|
- 建立 storage link
|
||||||
|
- 產生 Swagger 文件
|
||||||
|
|
||||||
|
如需手動執行,可使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec app composer install
|
||||||
|
docker compose exec app php artisan migrate
|
||||||
|
docker compose exec app php artisan storage:link
|
||||||
|
docker compose exec app php artisan l5-swagger:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
清除 Laravel 快取:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec app php artisan config:clear
|
||||||
|
docker compose exec app php artisan cache:clear
|
||||||
|
docker compose exec app php artisan route:clear
|
||||||
|
docker compose exec app php artisan view:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 建立管理員帳號
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec app php artisan app:create-admin "Admin" "admin@example.com" --password="your_password"
|
||||||
|
```
|
||||||
|
|
||||||
|
密碼至少需要 8 碼。
|
||||||
|
|
||||||
|
## 7. 前端 Vite 開發模式
|
||||||
|
|
||||||
|
如果要使用本機 Vite 開發模式:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
正式建置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose 內也有 `frontend` 服務,預設網址為:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 重啟 frontend
|
||||||
|
|
||||||
|
如果 frontend 是透過 Docker Compose 啟動:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose restart frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
如果有修改 frontend 的 Dockerfile、環境變數或 build 內容,建議重建:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
如果是使用本機 Vite 開發模式,先在原本終端機按 `Ctrl + C` 停止,再重新執行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 服務網址
|
||||||
|
|
||||||
|
| 服務 | URL |
|
||||||
|
| --- | --- |
|
||||||
|
| API / Laravel + Nginx | <http://localhost:8080> |
|
||||||
|
| Frontend Docker | <http://localhost:5173> |
|
||||||
|
| phpMyAdmin | <http://localhost:8081> |
|
||||||
|
| Mailpit | <http://localhost:8025> |
|
||||||
|
| Reverb WebSocket | <ws://localhost:8085> |
|
||||||
|
| Health Check | <http://localhost:8080/health> |
|
||||||
|
|
||||||
|
## 10. Log 查看
|
||||||
|
|
||||||
|
Laravel 日誌走 stderr,透過 Docker 查看(不再有 `storage/logs/` 檔案):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 即時串流
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
# 查看最後 50 行
|
||||||
|
docker compose logs app --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. VPS 維護窗口操作(P1 部署)
|
||||||
|
|
||||||
|
PR merge 後 CI/CD 自動跑 composer/migrate/cache,完成後 SSH 進 VPS 執行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
|
||||||
|
# 1. 更新 .env(一次性)
|
||||||
|
sed -i 's/SESSION_DRIVER=database/SESSION_DRIVER=redis/' .env
|
||||||
|
sed -i 's/LOG_CHANNEL=stack/LOG_CHANNEL=stderr/' .env
|
||||||
|
sed -i '/^LOG_STACK=/d' .env
|
||||||
|
sed -i '/^LOG_DAILY_DAYS=/d' .env
|
||||||
|
|
||||||
|
# 2. Rebuild + 重啟(entrypoint 自動 config:clear)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 3. 驗證
|
||||||
|
docker compose ps # scheduler 應顯示 Up
|
||||||
|
docker compose logs app --tail=20 # 應有 stderr 日誌
|
||||||
|
docker compose exec redis redis-cli keys "laravel_session*" # 登入後應有 session key
|
||||||
|
docker compose exec app sh -c "which cron 2>/dev/null || echo 'cron binary not found'" # 應輸出 not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rollback(若需還原):**
|
||||||
|
|
||||||
|
Session/Log 還原(不需 rebuild):
|
||||||
|
```bash
|
||||||
|
sed -i 's/SESSION_DRIVER=redis/SESSION_DRIVER=database/' .env
|
||||||
|
sed -i 's/LOG_CHANNEL=stderr/LOG_CHANNEL=stack/' .env
|
||||||
|
echo "LOG_STACK=daily" >> .env
|
||||||
|
echo "LOG_DAILY_DAYS=14" >> .env
|
||||||
|
docker compose exec app php artisan config:clear
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
Scheduler 還原(需 rebuild):
|
||||||
|
```bash
|
||||||
|
git revert <commit-hash>
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 停止服務
|
||||||
|
|
||||||
|
停止容器,但保留資料庫 volume:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
停止容器並移除 volume,會清除 MySQL 資料:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最短啟動流程
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\laragon\www\CFDivePlatform
|
||||||
|
copy .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec app php artisan key:generate
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
啟動完成後開啟:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# CFDivePlatform 未來發展可行性評估
|
||||||
|
|
||||||
|
> 評估日期:2026-06-11
|
||||||
|
> 前提:核心閉環(瀏覽 → 預約 → 聊天 → 完課 → 評價)已完成並歸檔;本文評估下一階段候選項目的可行性、依賴與建議順序。
|
||||||
|
> 搭配閱讀:《2026-06-11-spec-implementation-audit.md》(修補計畫應先於所有新功能執行,尤其 P0-1)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、候選項目總覽
|
||||||
|
|
||||||
|
| # | 項目 | 可行性 | 預估工時 | 商業價值 | 建議優先序 |
|
||||||
|
|---|------|--------|---------|---------|-----------|
|
||||||
|
| 1 | 教練審核流程(完整版) | 高 | 3~5 天 | 高(信任基礎) | ★ 第 1 |
|
||||||
|
| 2 | 金流整合(綠界/藍新) | 中高 | 8~12 天 | 極高(變現核心) | ★ 第 2 |
|
||||||
|
| 3 | 即時聊天室 VPS 部署驗收 | 高 | 0.5~1 天 | 中(已開發未交付) | 隨時可做 |
|
||||||
|
| 4 | CI/CD(Gitea Actions)修復 | 中 | 1~2 天 | 中(工程效率) | 金流前必做 |
|
||||||
|
| 5 | 課程搜尋強化(地區/價格/日期篩選) | 高 | 2~3 天 | 中高 | 第 3 |
|
||||||
|
| 6 | 行動端體驗(PWA) | 中 | 5~8 天 | 中 | 觀望 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、逐項評估
|
||||||
|
|
||||||
|
### 2.1 教練審核流程(完整版)— 可行性:高
|
||||||
|
|
||||||
|
**現況基礎**:`provider_profiles.is_verified` 欄位、Admin `toggle-verified` 端點、Admin Panel UI 均已存在;缺的是審核「流程」與約束力(見稽核報告 P1-1)。
|
||||||
|
|
||||||
|
**範圍建議**:
|
||||||
|
1. 教練註冊後狀態為 `pending_review`(建議將 boolean `is_verified` 升級為 enum `verification_status`:pending / approved / rejected)。
|
||||||
|
2. 證照/資料上傳:複用既有 `CourseImageController` 的檔案處理模式(local storage,已有上傳驗證先例)。
|
||||||
|
3. Admin 審核佇列頁 + 通過/駁回(含駁回原因),複用既有 Admin Panel 的列表/操作 UI 模式。
|
||||||
|
4. 審核結果通知:複用 `notification-core` + `notification-email` 既有管線,只需新增 Notification class 與 trigger。
|
||||||
|
|
||||||
|
**技術風險**:低。所有積木(檔案上傳、通知、Admin UI、權限 middleware)都已存在,純組裝工作。
|
||||||
|
**主要決策點**:未通過審核的教練能做到哪一步(可登入編輯資料但不可上架?完全不可登入?)——需產品決策,建議前者。
|
||||||
|
**依賴**:先完成稽核報告 T2.1(最小語意),再擴充為完整流程,避免重工。
|
||||||
|
|
||||||
|
### 2.2 金流整合 — 可行性:中高(工程可行,依賴外部申請流程)
|
||||||
|
|
||||||
|
**金流商比較(台灣市場)**:
|
||||||
|
|
||||||
|
| 面向 | 綠界 ECPay | 藍新 NewebPay |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| 申請門檻 | 個人/公司皆可,測試環境免申請即用 | 偏好公司戶,測試環境需註冊 |
|
||||||
|
| 文件/社群資源 | Laravel 套件與範例多(生態最成熟) | 文件完整但第三方資源較少 |
|
||||||
|
| 手續費 | 信用卡約 2.75%~ | 相近 |
|
||||||
|
| 結論 | **建議首選**,沙箱即開即用、整合範例多 | 備選 |
|
||||||
|
|
||||||
|
**對現有架構的影響(這是工時主要來源)**:
|
||||||
|
1. **預約狀態機需擴充**:現有七狀態未含付款概念,需插入 `awaiting_payment`、`paid` 等狀態或建立獨立 `payments` 資料表(建議後者:獨立 Payment model + 與 Booking 一對多,狀態機改動最小,符合開放封閉原則)。
|
||||||
|
2. **Webhook 端點**:綠界以 server-side POST 回傳付款結果,需公開端點 + CheckMacValue 驗證 + 冪等處理(重複通知不可重複入帳)——此為資安重點,須走 CLAUDE.md 安全確認流程。
|
||||||
|
3. **退款政策**:取消預約的退款規則(全額/比例/不退)是產品決策,直接影響狀態機與對帳邏輯,**須在開工前定案**。
|
||||||
|
4. **金額快照**:現有 booking 已記錄價格快照(`booking-lifecycle` 規格),付款金額有可靠依據,這是現成優勢。
|
||||||
|
|
||||||
|
**前置條件**:
|
||||||
|
- [ ] 確定金流商並取得測試商店(綠界沙箱可立即取得)。
|
||||||
|
- [ ] 退款政策定案。
|
||||||
|
- [ ] **稽核報告 P0-1 必須先修**(金流上線後,管理端漏洞的損害會從資料外洩升級為金錢損失)。
|
||||||
|
- [ ] 建議 CI 先行(2.4),金流程式碼回歸成本高,不應依賴手動測試。
|
||||||
|
|
||||||
|
**工時拆解(粗估)**:Payment model + 狀態整合 3 天、ECPay 串接與 Webhook 3 天、前端付款流程 2 天、測試(含冪等與失敗路徑)2~4 天。
|
||||||
|
|
||||||
|
### 2.3 即時聊天室 VPS 部署驗收 — 可行性:高
|
||||||
|
|
||||||
|
程式碼已 merge(commit 5ba598c 後系列),記憶中部署狀態未確認。剩餘工作為運維執行:rebuild frontend、restart reverb、`.env` 五個值(細節在既有部署清單中)。建議排入下次部署窗口一次完成,避免長期掛著「已完成未交付」狀態。
|
||||||
|
|
||||||
|
### 2.4 CI/CD(Gitea Actions)修復 — 可行性:中
|
||||||
|
|
||||||
|
已知 runner 的 self-hosted 標籤問題尚未解決。價值在於:測試套件已有 97 案例且將隨修補計畫 Phase 3 成長,沒有 CI 的測試套件會逐漸失去約束力。建議在金流開工前修復,作為金流開發的安全網。風險:Gitea runner 在 Windows/Docker 環境的相容性問題可能再耗時,若兩天內無法解決,備案是改用簡單的 pre-push hook 或部署前手動 gate。
|
||||||
|
|
||||||
|
### 2.5 課程搜尋強化 — 可行性:高
|
||||||
|
|
||||||
|
現有 `GET /api/diving-offers` 已支援關鍵字與分頁;擴充地區/價格區間/可預約日期篩選屬增量開發。注意事項:日期篩選需 join schedules 並留意 N+1 與索引(`db-index-optimization` 規格已有索引設計先例可循);快取層(`api-cache-layer`)的 cache key 需納入新篩選參數。
|
||||||
|
|
||||||
|
### 2.6 行動端 PWA — 可行性:中,建議觀望
|
||||||
|
|
||||||
|
Vue 3 + Vite 加 PWA plugin 技術上直接,但通知推播(Web Push)與 iOS 限制會吃掉多數工時,且現階段使用者基數未驗證。建議待金流上線、有真實流量後再評估。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、建議路線圖
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 1 修補計畫 Phase 1~4(P0 封鎖 + 規格同步 + 預約測試)
|
||||||
|
└─ 並行:聊天室 VPS 部署驗收(2.3)
|
||||||
|
Week 2 教練審核流程完整版(2.1)+ CI 修復(2.4)
|
||||||
|
Week 3~4 金流整合(2.2):Payment model → ECPay 沙箱 → Webhook → 前端
|
||||||
|
Week 5 金流測試強化 + 課程搜尋強化(2.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
**關鍵判斷**:
|
||||||
|
1. 教練審核排在金流之前——付費平台上架未經審核的教練,發生糾紛時平台責任更重;審核機制是金流的信任前提。
|
||||||
|
2. 金流是唯一含外部依賴(商店申請、Webhook 公網可達性)的項目,沙箱申請應在 Week 1 就先送出,與其他工作並行等待。
|
||||||
|
3. 所有新功能開工前,稽核報告的 P0-1(admin register 漏洞)必須先關閉。
|
||||||
|
|
||||||
|
## 四、不建議現在做的事
|
||||||
|
|
||||||
|
- **微服務化 / 抽換架構**:單體 Laravel 在現階段流量下完全足夠,拆分只會增加部署與除錯成本。
|
||||||
|
- **多金流商抽象層**:先用介面包一層 `PaymentGatewayContract` 即可(依賴反轉),但不要實作第二家金流——沒有需求前是投機性程式碼(違反 Simplicity First)。
|
||||||
|
- **評價 AI 審核 / 推薦系統**:資料量不足,無訓練與評估基礎。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# CFDivePlatform 操作體驗優化計畫
|
||||||
|
|
||||||
|
> 撰寫日期:2026-06-11
|
||||||
|
> 背景:使用者反映本機操作體驗差、回應慢。本文以實測數據定位根因,並依投資報酬率排序優化項目。
|
||||||
|
> **狀態更新(2026-06-11)**:Phase 1 已執行完成,穩態延遲 2.5s → **0.2s**(約 10 倍改善)。執行結果見第三節,根因分析已依實測修正。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、實測基準(2026-06-11 上午,修復前)
|
||||||
|
|
||||||
|
| 量測項目 | 數值 | 備註 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `GET /api/diving-offers`(冷) | **7.98 秒** | 經 nginx:8080 → php-fpm |
|
||||||
|
| 同端點(Redis 快取命中、間隔數秒) | **2.3~2.5 秒** | 快取命中仍要 2.5 秒 → 瓶頸不在查詢 |
|
||||||
|
| 同端點(2 秒內連發) | 0.23 秒 | 關鍵線索:短窗口內其實很快 |
|
||||||
|
| 容器內全測試套件 | 64.4 秒 | 同套件本機 PHP 僅 4.5 秒 |
|
||||||
|
|
||||||
|
## 二、根因分析(依實測修正)
|
||||||
|
|
||||||
|
> ⚠️ 初版文檔判定「OPcache 被停用」是**錯誤診斷**——當時用 `php -r`(CLI)量測,而 `opcache.enable_cli` 預設關閉造成假象。php-fpm 的 OPcache 一直是啟用的(851 個腳本在快取中)。正確根因如下:
|
||||||
|
|
||||||
|
1. **`opcache.revalidate_freq=2`(預設值)× Windows bind mount**:每隔 2 秒,下一個請求就要跨 Windows↔Linux 檔案系統邊界重新 stat 全部 ~850 個已快取腳本。實測鐵證:2 秒窗口內 0.23s,窗口過期後 2.3~2.5s。一般操作節奏(點一下、看一下、再點)幾乎每次都踩在窗口外,所以「每個操作都慢」。
|
||||||
|
2. **Entrypoint 的 composer 重裝條件用 mtime 比對**:`composer.json -nt vendor/autoload.php`——git checkout/pull 會更新 composer.json 的 mtime,導致每次分支操作後的容器啟動都重跑 `composer install`(bind mount 上 autoload 生成耗時數分鐘,**期間 php-fpm 未啟動、全站 502**)。
|
||||||
|
3. **nginx healthcheck 誤報**:busybox wget 對 `localhost` 先解析 IPv6 `::1`,nginx 只 listen IPv4 → 永遠 connection refused → 永遠 unhealthy。
|
||||||
|
4. 觀察項(未處理):cron 每分鐘 `schedule:run` 以 CLI 啟動 PHP(CLI 無 OPcache + bind mount),造成偶發 ~2 秒突波;`SESSION/QUEUE=database`;`APP_DEBUG=true`。
|
||||||
|
|
||||||
|
## 三、Phase 1 執行紀錄(2026-06-11 完成,branch perf/phase1-opcache)
|
||||||
|
|
||||||
|
| 項目 | 修改 | 結果 |
|
||||||
|
|------|------|------|
|
||||||
|
| O1.1 OPcache 調校 | `docker/php/local.ini`:`revalidate_freq` 2→**30**、memory 128→192、max_files 10000→20000 | 穩態 **0.20~0.27s**(前:2.3~2.5s);每 30 秒閒置後僅一次 ~1.2s revalidate |
|
||||||
|
| O1.2 healthcheck 修復 | docker-compose.yml:`http://localhost/` → `http://127.0.0.1/` | nginx 顯示 **healthy** |
|
||||||
|
| O1.3 composer 重裝條件 | entrypoint 改為 composer.lock **內容比對**(`cmp` vs `vendor/.composer.lock.installed` 標記檔) | git 分支操作不再觸發開機重裝與 502 窗口 |
|
||||||
|
|
||||||
|
### 修復後量測(同一端點、每 5 秒一次)
|
||||||
|
|
||||||
|
```
|
||||||
|
#1: 1.96s(cron 突波) #2: 0.22s #3: 0.21s #4: 0.20s #5: 0.27s #6: 0.26s
|
||||||
|
閒置 35 秒後:1.19s(revalidate,每 30 秒至多一次)→ 下一次 0.30s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 開發注意事項
|
||||||
|
|
||||||
|
- 後端程式碼變更最多 **30 秒**後生效;要立即生效:`docker compose exec app kill -USR2 1`(php-fpm 平滑重載,約 1 秒)
|
||||||
|
- `local.ini` 與 entrypoint 是 build 進 image 的,改動需 `docker compose build app && docker compose up -d app`
|
||||||
|
- 此設定對 VPS 同樣適用且有益(Linux 原生 stat 便宜,revalidate=30 純賺)
|
||||||
|
|
||||||
|
## 四、後續階段(依新數據重新評估)
|
||||||
|
|
||||||
|
### Phase 2 — 結構性(暫緩)
|
||||||
|
|
||||||
|
原計畫的 vendor named volume / WSL2 遷移以「Phase 1 後仍 >1s」為觸發條件——實測穩態 0.2s,**未達觸發條件,暫緩**。若未來 cron 突波造成困擾,優先處理觀察項 4(如 schedule:run 改由長駐 worker、或縮減無任務時的 bootstrap)。
|
||||||
|
|
||||||
|
### Phase 3 — 應用層體感(與環境無關,部署後使用者也受益,1~2 天)
|
||||||
|
|
||||||
|
**O3.1 課程封面圖未壓縮**:聊天圖片已有 `scaleDown(2048) + toJpeg(85)`(`BookingMessageController.php:115`),但 `CourseImageController` 原檔直存。建議套用相同縮圖管線 + 前端 `loading="lazy"`。應開 openspec change(影響 `course-image-upload` 規格)。
|
||||||
|
|
||||||
|
**O3.2 無分頁的列表端點**:`/api/member/bookings`、`/api/provider/bookings`、`/api/admin/bookings` 皆 `->get()` 全量。資料量成長後線性變慢,建議金流上線前補分頁。
|
||||||
|
|
||||||
|
**O3.3 前端載入體感**:列表頁 skeleton 佔位、搜尋 debounce(300ms)。路由已全面 lazy-load(已驗證,不需處理)。
|
||||||
|
|
||||||
|
### 生產環境註記(部署時順手做)
|
||||||
|
|
||||||
|
- `php artisan optimize`(config / route / view cache)
|
||||||
|
- 生產 OPcache 可進一步 `validate_timestamps=0`(部署時 `kill -USR2 1` 重載刷新)
|
||||||
|
|
||||||
|
## 五、驗收方式
|
||||||
|
|
||||||
|
每步以同一 curl 量測對照(`curl -s -o /dev/null -w "%{time_total}" http://127.0.0.1:8080/api/diving-offers`),數據寫回本文件。
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# CFDivePlatform 規格與實作分析報告暨修補紀錄
|
||||||
|
|
||||||
|
> 分析日期:2026-06-11
|
||||||
|
> 分析範圍:`openspec/specs/`(32 份規格)vs 後端實作(Laravel 11)、前端(`frontend/` Vue 3)、測試(`tests/`)
|
||||||
|
> 分析基準:branch `test/auth-tests-coverage`(commit ca5b843)
|
||||||
|
> **狀態更新(2026-06-11)**:修補計畫四階段已全部完成於 branch `fix/audit-remediation`(PR #28),本文件保留發現紀錄與執行結果,未完成事項見第四節。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、現況總覽
|
||||||
|
|
||||||
|
### 1.1 規格狀態
|
||||||
|
|
||||||
|
- OpenSpec 規格共 **32 份**(修補後 33 份,新增 `provider-verification`),全部 change 已歸檔。
|
||||||
|
- 涵蓋範圍:認證安全(lockout / rate-limiting / OAuth state / token refresh)、三角色入口(Member / Coach / Admin)、預約生命週期、評價系統、通知系統、即時聊天、Swagger 文件、效能優化。
|
||||||
|
|
||||||
|
### 1.2 實作狀態(修補後)
|
||||||
|
|
||||||
|
| 模組 | 規格 | 實作 | 測試 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 認證(三角色登入/註冊/登出) | ✅ | ✅ | ✅ |
|
||||||
|
| 帳號鎖定 + OAuth state | ✅ | ✅ | ✅ |
|
||||||
|
| Token Refresh | ✅ | ✅ | ✅ |
|
||||||
|
| 課程 CRUD + 圖片上傳 | ✅ | ✅ | ✅ |
|
||||||
|
| 預約生命週期(七狀態機、防超賣、Scheduler) | ✅ | ✅ | ✅(修補新增 26 案例) |
|
||||||
|
| 評價系統 | ✅ | ✅ | ✅ |
|
||||||
|
| 通知系統 | ✅ | ✅ | ⚠️ 僅 Scheduler 路徑覆蓋 |
|
||||||
|
| 即時聊天 + Presence | ✅ | ✅ | ✅(授權路徑,修補新增 8 案例) |
|
||||||
|
| Admin 管理端點 | ✅ | ✅ | ✅(權限邊界,修補新增 5 案例) |
|
||||||
|
| 教練驗證(is_verified 最小語意) | ✅(修補新增) | ✅ | ✅(7 案例) |
|
||||||
|
| 教練審核流程(完整版) | ❌ 無規格 | ❌ | — |
|
||||||
|
| 金流整合 | ❌ 無規格 | ❌ | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、稽核發現(保留原始紀錄,狀態見第三節)
|
||||||
|
|
||||||
|
### 🔴 P0-1:`POST /api/admin/register` 完全公開,任何人可註冊管理員帳號
|
||||||
|
|
||||||
|
- 該路由無任何 middleware、無邀請碼;request 通過基本驗證後直接 `role => 'admin'` 建立帳號。攻擊者一個 HTTP 請求即可取得全平台管理權限。`admin-auth` 規格從未定義此端點(規格外實作)。
|
||||||
|
|
||||||
|
### 🟠 P1-1:`is_verified`(教練驗證)從未被任何業務邏輯強制執行
|
||||||
|
|
||||||
|
- `is_verified` 只有 Admin toggle 開關與 Model 屬性,教練註冊後不需任何審核即可上架、曝光、接單。無任何規格定義其業務語意。
|
||||||
|
|
||||||
|
### 🟠 P1-2:登入頻率限制規格與實作不一致(規格漂移)
|
||||||
|
|
||||||
|
- 規格寫 Member/Provider 5 次/分鐘,實作 `throttle:10,1`,測試(commit 0dabc4e)已配合實作改為 10 次,規格未同步。
|
||||||
|
|
||||||
|
### 🟡 P2-1:核心業務流程(預約)零測試覆蓋
|
||||||
|
|
||||||
|
- 預約狀態機、防超賣鎖定、Scheduler 自動過期/完成沒有任何測試;時段管理、通知觸發、聊天授權、Admin 端點亦無。
|
||||||
|
|
||||||
|
### 🟡 P2-2:規格維護債
|
||||||
|
|
||||||
|
- `auth-test-coverage` Purpose 為 TBD;`member-portal-ui` repo 描述過時;`admin-auth` 未涵蓋 check-member / check-provider 端點。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修補執行紀錄(2026-06-11 全部完成)
|
||||||
|
|
||||||
|
分支 `fix/audit-remediation`(基底 `test/auth-tests-coverage`,PR #28),容器內全套件 **146 passed / 378 assertions**。
|
||||||
|
|
||||||
|
| 對應發現 | 修補內容 | Commit |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| P0-1 | 移除公開 admin/register(路由/controller/Swagger),新增 `php artisan app:create-admin`(min:8),`AdminAccountCreationTest` 4 案例,`admin-auth` 規格補「帳號建立途徑」 | aeb8c97 |
|
||||||
|
| P1-2 | `login-rate-limiting` 規格同步至 10/min(以實作為準,註記放寬理由) | 88a81aa |
|
||||||
|
| P1-1 | `DivingOffer::visibleToPublic` scope(公開 index/show 過濾未驗證教練課程)、toggle-verified 後 flush 快取、新增 `provider-verification` 規格、`DivingOfferVisibilityTest` 7 案例 | 3c38d08 |
|
||||||
|
| P2-1 | 預約核心 39 案例:狀態機 17、防超賣 3、Scheduler 6、聊天授權 8、Admin 權限邊界 5 | 63b25f9 |
|
||||||
|
| P2-2 | 規格清理(Purpose / repo 描述 / 查詢端點)+ 同步移除測已刪端點的 2 個舊測試 | d3a0d30, cec2078 |
|
||||||
|
| — | OpenSpec 補歸檔 `2026-06-11-audit-remediation`(proposal / design / tasks / 規格增量) | a97a7d0 |
|
||||||
|
|
||||||
|
**與原計畫的偏差**:原計畫建議「Demo seeder 將教練預設 `is_verified=true`」,實際保留 seeder 中 1 位未驗證教練——其課程從公開列表消失正好展示新規則,屬刻意決策(記錄於 PR #28 說明)。
|
||||||
|
|
||||||
|
## 四、尚未處理事項
|
||||||
|
|
||||||
|
1. **provider-verification 已知限制**(規格 Notes 已載明):`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews` 與預約建立流程尚未套用可見性過濾,知道課程 id 仍可間接互動。
|
||||||
|
→ **已開 OpenSpec change `provider-verification-gaps` 規劃**(`openspec/changes/provider-verification-gaps/`),待實作。
|
||||||
|
2. **操作體驗優化**:見 `docs/analysis/2026-06-11-performance-optimization-plan.md`(O1.1 啟用 OPcache 起步),尚未實施。
|
||||||
|
3. **新功能路線圖**:教練審核完整版、金流整合等,見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md`。
|
||||||
|
|
||||||
|
## 五、規格同步狀態聲明
|
||||||
|
|
||||||
|
修補已同步的規格:`admin-auth`、`login-rate-limiting`、`auth-test-coverage`、`member-portal-ui`、新增 `provider-verification`;變更軌跡歸檔於 `openspec/changes/archive/2026-06-11-audit-remediation/`。
|
||||||
@@ -8,7 +8,16 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_URL=http://localhost:8080
|
ARG VITE_API_URL=http://localhost:8080
|
||||||
|
ARG VITE_REVERB_APP_KEY
|
||||||
|
ARG VITE_REVERB_HOST=localhost
|
||||||
|
ARG VITE_REVERB_PORT=8085
|
||||||
|
ARG VITE_REVERB_SCHEME=http
|
||||||
|
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY
|
||||||
|
ENV VITE_REVERB_HOST=$VITE_REVERB_HOST
|
||||||
|
ENV VITE_REVERB_PORT=$VITE_REVERB_PORT
|
||||||
|
ENV VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
Generated
+230
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -450,6 +452,12 @@
|
|||||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -890,6 +898,23 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -938,6 +963,28 @@
|
|||||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||||
|
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.20.1",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -1315,6 +1362,18 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/laravel-echo": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pusher-js": "*",
|
||||||
|
"socket.io-client": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1656,6 +1715,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -1946,6 +2011,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -2097,6 +2170,34 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2257,6 +2358,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2408,6 +2514,36 @@
|
|||||||
"version": "6.6.4",
|
"version": "6.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2664,6 +2800,12 @@
|
|||||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"@tybys/wasm-util": {
|
"@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -2979,6 +3121,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"delayed-stream": {
|
"delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -3018,6 +3169,25 @@
|
|||||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"engine.io-client": {
|
||||||
|
"version": "6.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||||
|
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.20.1",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"entities": {
|
"entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -3263,6 +3433,12 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"laravel-echo": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"lightningcss": {
|
"lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -3427,6 +3603,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||||
},
|
},
|
||||||
|
"ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"mz": {
|
"mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -3578,6 +3760,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
|
||||||
},
|
},
|
||||||
|
"pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"requires": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"queue-microtask": {
|
"queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -3675,6 +3865,28 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source-map-js": {
|
"source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3794,6 +4006,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
|
||||||
|
},
|
||||||
"update-browserslist-db": {
|
"update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -3850,6 +4067,19 @@
|
|||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ws": {
|
||||||
|
"version": "8.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
|
"xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"peer": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,11 +6,76 @@ const api = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = sessionStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let isRedirecting = false
|
||||||
|
let pendingRequests = []
|
||||||
|
|
||||||
|
function resolvePending(token) {
|
||||||
|
pendingRequests.forEach((cb) => cb(token))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPending(error) {
|
||||||
|
pendingRequests.forEach((cb) => cb(null, error))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config
|
||||||
|
const status = error.response?.status
|
||||||
|
|
||||||
|
const isAuthEndpoint =
|
||||||
|
config.url.includes('/login') ||
|
||||||
|
config.url.includes('/register') ||
|
||||||
|
config.url.includes('/refresh')
|
||||||
|
|
||||||
|
if (status !== 401 || isAuthEndpoint || config._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push((token, err) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
resolve(api(config))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
config._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/member/refresh')
|
||||||
|
const newToken = data.data.token
|
||||||
|
sessionStorage.setItem('token', newToken)
|
||||||
|
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||||
|
resolvePending(newToken)
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return api(config)
|
||||||
|
} catch {
|
||||||
|
rejectPending(error)
|
||||||
|
if (!isRedirecting) {
|
||||||
|
isRedirecting = true
|
||||||
|
sessionStorage.removeItem('token')
|
||||||
|
sessionStorage.removeItem('user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -6,11 +6,76 @@ const coachApi = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
coachApi.interceptors.request.use((config) => {
|
coachApi.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('coach_token')
|
const token = sessionStorage.getItem('coach_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let isRedirecting = false
|
||||||
|
let pendingRequests = []
|
||||||
|
|
||||||
|
function resolvePending(token) {
|
||||||
|
pendingRequests.forEach((cb) => cb(token))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPending(error) {
|
||||||
|
pendingRequests.forEach((cb) => cb(null, error))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
coachApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config
|
||||||
|
const status = error.response?.status
|
||||||
|
|
||||||
|
const isAuthEndpoint =
|
||||||
|
config.url.includes('/login') ||
|
||||||
|
config.url.includes('/register') ||
|
||||||
|
config.url.includes('/refresh')
|
||||||
|
|
||||||
|
if (status !== 401 || isAuthEndpoint || config._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push((token, err) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
resolve(coachApi(config))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
config._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await coachApi.post('/provider/refresh')
|
||||||
|
const newToken = data.data.token
|
||||||
|
sessionStorage.setItem('coach_token', newToken)
|
||||||
|
coachApi.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||||
|
resolvePending(newToken)
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return coachApi(config)
|
||||||
|
} catch {
|
||||||
|
rejectPending(error)
|
||||||
|
if (!isRedirecting) {
|
||||||
|
isRedirecting = true
|
||||||
|
sessionStorage.removeItem('coach_token')
|
||||||
|
sessionStorage.removeItem('coach_user')
|
||||||
|
window.location.href = '/coach/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default coachApi
|
export default coachApi
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ notificationApi.interceptors.request.use((config) => {
|
|||||||
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
||||||
const isCoachPage = window.location.pathname.startsWith('/coach')
|
const isCoachPage = window.location.pathname.startsWith('/coach')
|
||||||
const token = isCoachPage
|
const token = isCoachPage
|
||||||
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
|
? (sessionStorage.getItem('coach_token') || sessionStorage.getItem('token'))
|
||||||
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
|
: (sessionStorage.getItem('token') || sessionStorage.getItem('coach_token'))
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import api from '../api/axios'
|
||||||
|
import coachApi from '../api/coachAxios'
|
||||||
|
import echo from '../plugins/echo'
|
||||||
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
bookingId: { type: Number, required: true },
|
||||||
|
bookingStatus: { type: String, required: true },
|
||||||
|
currentUserType: { type: String, required: true }, // 'member' | 'provider'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['read'])
|
||||||
|
|
||||||
|
const messages = ref([])
|
||||||
|
const messageListRef = ref(null)
|
||||||
|
const textInput = ref('')
|
||||||
|
const isSending = ref(false)
|
||||||
|
const otherUserOnline = ref(false)
|
||||||
|
const channel = ref(null)
|
||||||
|
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
const isConfirmed = computed(() => props.bookingStatus === 'confirmed')
|
||||||
|
const isCompleted = computed(() => props.bookingStatus === 'completed')
|
||||||
|
const canSend = computed(() => isConfirmed.value && !isSending.value && textInput.value.trim())
|
||||||
|
const otherType = computed(() => props.currentUserType === 'member' ? 'provider' : 'member')
|
||||||
|
|
||||||
|
const axiosInstance = computed(() => props.currentUserType === 'provider' ? coachApi : api)
|
||||||
|
|
||||||
|
// 請求瀏覽器通知權限(只問一次)
|
||||||
|
async function requestBrowserNotificationPermission() {
|
||||||
|
if ('Notification' in window && Notification.permission === 'default') {
|
||||||
|
await Notification.requestPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用者不在頁面時才推瀏覽器通知
|
||||||
|
function showBrowserNotification(msg) {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||||
|
if (!document.hidden) return // 使用者正在看這個 tab,不需要
|
||||||
|
|
||||||
|
const body = msg.type === 'image' ? '傳送了一張圖片' : msg.content
|
||||||
|
new Notification('新訊息', {
|
||||||
|
body,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
tag: `booking-chat-${props.bookingId}`, // 同一個預約只顯示一則,不疊加
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messageListRef.value) {
|
||||||
|
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.value.get(`/bookings/${props.bookingId}/messages`)
|
||||||
|
messages.value = res.data.data
|
||||||
|
scrollToBottom()
|
||||||
|
await markLastRead()
|
||||||
|
// 使用者打開聊天室後已讀,立刻刷新 bell badge
|
||||||
|
notificationStore.fetchUnreadCount()
|
||||||
|
} catch (e) {
|
||||||
|
// 403 means no access, silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markLastRead() {
|
||||||
|
if (!messages.value.length) return
|
||||||
|
const lastId = messages.value[messages.value.length - 1].id
|
||||||
|
try {
|
||||||
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages/read`, {
|
||||||
|
last_read_message_id: lastId,
|
||||||
|
})
|
||||||
|
messages.value.forEach(m => {
|
||||||
|
if (m.sender_type !== props.currentUserType && !m.read_at) {
|
||||||
|
m.read_at = new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
emit('read') // 通知父層清除未讀角標
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText() {
|
||||||
|
if (!canSend.value) return
|
||||||
|
const content = textInput.value.trim()
|
||||||
|
textInput.value = ''
|
||||||
|
isSending.value = true
|
||||||
|
|
||||||
|
const tempId = `_temp_${Date.now()}`
|
||||||
|
messages.value.push({
|
||||||
|
id: tempId,
|
||||||
|
sender_id: null,
|
||||||
|
sender_type: props.currentUserType,
|
||||||
|
type: 'text',
|
||||||
|
content,
|
||||||
|
read_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, {
|
||||||
|
type: 'text',
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
textInput.value = content
|
||||||
|
messages.value = messages.value.filter(m => m.id !== tempId)
|
||||||
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendImage(event) {
|
||||||
|
if (!isConfirmed.value) return
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
event.target.value = ''
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('type', 'image')
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
isSending.value = true
|
||||||
|
try {
|
||||||
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('圖片上傳失敗', e)
|
||||||
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeChannel() {
|
||||||
|
channel.value = echo.join(`booking.${props.bookingId}`)
|
||||||
|
.here(users => {
|
||||||
|
otherUserOnline.value = users.some(u => u.user_type === otherType.value)
|
||||||
|
// Reverb 不會發 member_added,主動 whisper 告知對方自己已上線
|
||||||
|
channel.value?.whisper('presence', { user_type: props.currentUserType, online: true })
|
||||||
|
})
|
||||||
|
.joining(user => {
|
||||||
|
if (user.user_type === otherType.value) otherUserOnline.value = true
|
||||||
|
})
|
||||||
|
.leaving(user => {
|
||||||
|
if (user.user_type === otherType.value) otherUserOnline.value = false
|
||||||
|
})
|
||||||
|
.listenForWhisper('presence', (e) => {
|
||||||
|
if (e.user_type === otherType.value) otherUserOnline.value = e.online
|
||||||
|
})
|
||||||
|
.listen('.MessageSent', async (e) => {
|
||||||
|
if (e.sender_type === props.currentUserType) {
|
||||||
|
// 自己送的訊息:替換樂觀更新的暫存訊息(避免重複)
|
||||||
|
const tempIdx = messages.value.findIndex(
|
||||||
|
m => typeof m.id === 'string' && m.id.startsWith('_temp_') && m.content === e.content
|
||||||
|
)
|
||||||
|
if (tempIdx !== -1) {
|
||||||
|
messages.value[tempIdx] = { id: e.id, sender_id: e.sender_id, sender_type: e.sender_type, type: e.type, content: e.content, read_at: null, created_at: e.created_at }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.value.push({
|
||||||
|
id: e.id,
|
||||||
|
sender_id: e.sender_id,
|
||||||
|
sender_type: e.sender_type,
|
||||||
|
type: e.type,
|
||||||
|
content: e.content,
|
||||||
|
read_at: null,
|
||||||
|
created_at: e.created_at,
|
||||||
|
})
|
||||||
|
scrollToBottom()
|
||||||
|
if (e.sender_type !== props.currentUserType) {
|
||||||
|
showBrowserNotification(e)
|
||||||
|
notificationStore.fetchUnreadCount()
|
||||||
|
await markLastRead()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.listen('.MessageRead', (e) => {
|
||||||
|
if (e.reader_type !== props.currentUserType) {
|
||||||
|
messages.value.forEach(m => {
|
||||||
|
if (m.sender_type === props.currentUserType && m.id <= e.last_read_message_id) {
|
||||||
|
m.read_at = m.read_at || new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateEchoToken() 會 disconnect/connect,connection 重建後需重訂 presence channel
|
||||||
|
// Pusher.js 的重連事件走 state_change,用 previous 判斷是否為重連而非初次連線
|
||||||
|
let everConnected = false
|
||||||
|
function onConnectionStateChange({ current }) {
|
||||||
|
if (current !== 'connected') return
|
||||||
|
if (!everConnected) { everConnected = true; return }
|
||||||
|
// 重連:重訂 presence channel
|
||||||
|
if (!isConfirmed.value) return
|
||||||
|
echo.leave(`booking.${props.bookingId}`)
|
||||||
|
channel.value = null
|
||||||
|
subscribeChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await requestBrowserNotificationPermission()
|
||||||
|
await loadHistory()
|
||||||
|
if (isConfirmed.value) {
|
||||||
|
subscribeChannel()
|
||||||
|
echo.connector.pusher.connection.bind('state_change', onConnectionStateChange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
echo.connector.pusher.connection.unbind('state_change', onConnectionStateChange)
|
||||||
|
if (channel.value) {
|
||||||
|
channel.value.whisper('presence', { user_type: props.currentUserType, online: false })
|
||||||
|
echo.leave(`booking.${props.bookingId}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isConfirmed || isCompleted" class="flex flex-col h-full border rounded-lg overflow-hidden">
|
||||||
|
<!-- 頂部狀態列 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b text-sm">
|
||||||
|
<span class="font-medium text-gray-700">訊息</span>
|
||||||
|
<div v-if="isConfirmed" class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
:class="otherUserOnline ? 'bg-green-400' : 'bg-gray-300'"
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-500">{{ otherUserOnline ? '對方在線' : '對方離線' }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400">對話已封存</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 訊息列表 -->
|
||||||
|
<div ref="messageListRef" class="flex-1 overflow-y-auto p-4 space-y-3 bg-white" style="max-height: 400px">
|
||||||
|
<div v-if="messages.length === 0" class="text-center text-gray-400 text-sm py-8">
|
||||||
|
尚無訊息
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:class="msg.sender_type === currentUserType ? 'items-end' : 'items-start'"
|
||||||
|
class="flex flex-col"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="msg.sender_type === currentUserType
|
||||||
|
? 'bg-blue-500 text-white rounded-br-none'
|
||||||
|
: 'bg-gray-100 text-gray-800 rounded-bl-none'"
|
||||||
|
class="max-w-[75%] px-3 py-2 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="msg.type === 'image'"
|
||||||
|
:src="msg.content"
|
||||||
|
alt="圖片訊息"
|
||||||
|
class="max-w-full rounded-lg"
|
||||||
|
style="max-height: 200px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5 text-[10px] text-gray-400">
|
||||||
|
<span>{{ new Date(msg.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||||
|
<span v-if="msg.sender_type === currentUserType">
|
||||||
|
{{ msg.read_at ? '已讀' : '未讀' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 輸入區(僅 confirmed) -->
|
||||||
|
<div v-if="isConfirmed" class="border-t bg-white p-3">
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<label class="flex-shrink-0 cursor-pointer text-gray-400 hover:text-blue-500 transition">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="sendImage" :disabled="isSending" />
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="textInput"
|
||||||
|
rows="1"
|
||||||
|
placeholder="輸入訊息..."
|
||||||
|
class="flex-1 resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
||||||
|
style="max-height: 80px"
|
||||||
|
@keydown.enter.exact.prevent="sendText"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="sendText"
|
||||||
|
:disabled="!canSend"
|
||||||
|
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-blue-500 text-white disabled:opacity-40 transition hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 封存提示(completed) -->
|
||||||
|
<div v-if="isCompleted" class="border-t bg-gray-50 px-4 py-3 text-center text-sm text-gray-400">
|
||||||
|
課程已結束,對話已封存
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -23,6 +23,7 @@ async function handleLogout() {
|
|||||||
<RouterLink to="/coach/schedules" 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/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/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
|
||||||
|
<RouterLink to="/coach/verification" class="text-sm hover:text-gray-300 transition">驗證申請</RouterLink>
|
||||||
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ defineProps({
|
|||||||
v-if="offer.cover_image_url"
|
v-if="offer.cover_image_url"
|
||||||
:src="offer.cover_image_url"
|
:src="offer.cover_image_url"
|
||||||
:alt="offer.title"
|
:alt="offer.title"
|
||||||
|
loading="lazy"
|
||||||
class="w-full h-full object-cover"
|
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 v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追蹤多個 booking 的未讀訊息數。
|
||||||
|
* @param {import('axios').AxiosInstance} axiosInstance - member 或 provider 的 axios
|
||||||
|
*/
|
||||||
|
export function useBookingUnreadCounts(axiosInstance) {
|
||||||
|
const counts = ref({}) // { [bookingId]: number }
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
async function fetchCounts() {
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.get('/bookings/messages/unread-counts')
|
||||||
|
counts.value = res.data.data ?? {}
|
||||||
|
} catch {
|
||||||
|
// 靜默失敗,不影響主要頁面
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 開啟聊天室後呼叫,清除該 booking 的角標 */
|
||||||
|
function clearCount(bookingId) {
|
||||||
|
counts.value = { ...counts.value, [bookingId]: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 頁面 mount 時呼叫,立即拉取一次並啟動 60s 輪詢 */
|
||||||
|
function startPolling() {
|
||||||
|
fetchCounts()
|
||||||
|
timer = setInterval(fetchCounts, 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
return { counts, fetchCounts, clearCount, startPolling, stopPolling }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import router from './router'
|
|||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useCoachAuthStore } from './stores/coachAuth'
|
import { useCoachAuthStore } from './stores/coachAuth'
|
||||||
import { useAdminAuthStore } from './stores/adminAuth'
|
import { useAdminAuthStore } from './stores/adminAuth'
|
||||||
|
import echo from './plugins/echo'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
@@ -19,4 +20,5 @@ useCoachAuthStore().init()
|
|||||||
useAdminAuthStore().init()
|
useAdminAuthStore().init()
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.config.globalProperties.$echo = echo
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Echo from 'laravel-echo'
|
||||||
|
import Pusher from 'pusher-js'
|
||||||
|
|
||||||
|
window.Pusher = Pusher
|
||||||
|
|
||||||
|
function getAuthToken() {
|
||||||
|
return sessionStorage.getItem('coach_token') || sessionStorage.getItem('token') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const echo = new Echo({
|
||||||
|
broadcaster: 'reverb',
|
||||||
|
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||||
|
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||||
|
wsPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
authEndpoint: `${import.meta.env.VITE_API_URL}/api/broadcasting/auth`,
|
||||||
|
auth: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getAuthToken()}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登入後更新 auth header 並強制重連,確保 Reverb 用新 token 授權 channel
|
||||||
|
export function updateEchoToken() {
|
||||||
|
const token = getAuthToken()
|
||||||
|
const bearer = `Bearer ${token}`
|
||||||
|
echo.options.auth.headers.Authorization = bearer
|
||||||
|
if (echo.connector?.pusher?.config?.auth?.headers) {
|
||||||
|
echo.connector.pusher.config.auth.headers.Authorization = bearer
|
||||||
|
}
|
||||||
|
echo.disconnect()
|
||||||
|
echo.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default echo
|
||||||
@@ -30,6 +30,7 @@ const routes = [
|
|||||||
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
|
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
|
||||||
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
|
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
|
||||||
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
|
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
|
||||||
|
{ path: 'verification', component: () => import('../views/coach/VerificationView.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import api from '../api/axios'
|
import api from '../api/axios'
|
||||||
import { useNotificationStore } from './notifications'
|
import { useNotificationStore } from './notifications'
|
||||||
|
import { updateEchoToken } from '../plugins/echo'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
@@ -10,32 +11,39 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const saved = localStorage.getItem('token')
|
const saved = sessionStorage.getItem('token')
|
||||||
const savedUser = localStorage.getItem('user')
|
const savedUser = sessionStorage.getItem('user')
|
||||||
if (saved) {
|
if (saved) {
|
||||||
token.value = saved
|
token.value = saved
|
||||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
ns.startRealtime(user.value?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuth(userData, tokenValue) {
|
function setAuth(userData, tokenValue) {
|
||||||
user.value = userData
|
user.value = userData
|
||||||
token.value = tokenValue
|
token.value = tokenValue
|
||||||
localStorage.setItem('token', tokenValue)
|
sessionStorage.setItem('token', tokenValue)
|
||||||
localStorage.setItem('user', JSON.stringify(userData))
|
sessionStorage.setItem('user', JSON.stringify(userData))
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
updateEchoToken()
|
||||||
|
ns.startRealtime(userData.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await api.post('/member/logout')
|
await api.post('/member/logout')
|
||||||
} catch {}
|
} catch {}
|
||||||
useNotificationStore().stopPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.stopRealtime()
|
||||||
|
ns.stopPolling()
|
||||||
user.value = null
|
user.value = null
|
||||||
token.value = null
|
token.value = null
|
||||||
localStorage.removeItem('token')
|
sessionStorage.removeItem('token')
|
||||||
localStorage.removeItem('user')
|
sessionStorage.removeItem('user')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import coachApi from '../api/coachAxios'
|
import coachApi from '../api/coachAxios'
|
||||||
import { useNotificationStore } from './notifications'
|
import { useNotificationStore } from './notifications'
|
||||||
|
import { updateEchoToken } from '../plugins/echo'
|
||||||
|
|
||||||
export const useCoachAuthStore = defineStore('coachAuth', () => {
|
export const useCoachAuthStore = defineStore('coachAuth', () => {
|
||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
@@ -10,32 +11,39 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
|
|||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const savedToken = localStorage.getItem('coach_token')
|
const savedToken = sessionStorage.getItem('coach_token')
|
||||||
const savedUser = localStorage.getItem('coach_user')
|
const savedUser = sessionStorage.getItem('coach_user')
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
token.value = savedToken
|
token.value = savedToken
|
||||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
ns.startRealtime(user.value?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuth(userData, tokenValue) {
|
function setAuth(userData, tokenValue) {
|
||||||
user.value = userData
|
user.value = userData
|
||||||
token.value = tokenValue
|
token.value = tokenValue
|
||||||
localStorage.setItem('coach_token', tokenValue)
|
sessionStorage.setItem('coach_token', tokenValue)
|
||||||
localStorage.setItem('coach_user', JSON.stringify(userData))
|
sessionStorage.setItem('coach_user', JSON.stringify(userData))
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
updateEchoToken()
|
||||||
|
ns.startRealtime(userData.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await coachApi.post('/provider/logout')
|
await coachApi.post('/provider/logout')
|
||||||
} catch {}
|
} catch {}
|
||||||
useNotificationStore().stopPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.stopRealtime()
|
||||||
|
ns.stopPolling()
|
||||||
user.value = null
|
user.value = null
|
||||||
token.value = null
|
token.value = null
|
||||||
localStorage.removeItem('coach_token')
|
sessionStorage.removeItem('coach_token')
|
||||||
localStorage.removeItem('coach_user')
|
sessionStorage.removeItem('coach_user')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import api from '../api/notificationAxios'
|
import api from '../api/notificationAxios'
|
||||||
|
import echo from '../plugins/echo'
|
||||||
|
|
||||||
export const useNotificationStore = defineStore('notifications', () => {
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
@@ -10,6 +11,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
let intervalId = null
|
let intervalId = null
|
||||||
let currentInterval = null
|
let currentInterval = null
|
||||||
let visibilityHandler = null
|
let visibilityHandler = null
|
||||||
|
let privateChannel = null // 訂閱 private user channel 用
|
||||||
|
|
||||||
async function fetchUnreadCount() {
|
async function fetchUnreadCount() {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +25,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 401) { stopPolling(); return }
|
||||||
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
|
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +36,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
notifications.value = res.data.data
|
notifications.value = res.data.data
|
||||||
unreadCount.value = res.data.unread_count
|
unreadCount.value = res.data.unread_count
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 401) { stopPolling(); return }
|
||||||
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
|
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,31 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 訂閱使用者的 private channel,收到 notification.created 立刻更新 bell。
|
||||||
|
* 在 startPolling() 後呼叫,需要傳入 userId。
|
||||||
|
*/
|
||||||
|
let realtimeUserId = null
|
||||||
|
|
||||||
|
function startRealtime(userId) {
|
||||||
|
if (!userId) return
|
||||||
|
stopRealtime() // 防止重複訂閱
|
||||||
|
realtimeUserId = userId
|
||||||
|
privateChannel = echo
|
||||||
|
.private(`App.Models.User.${userId}`)
|
||||||
|
.listen('.notification.created', () => {
|
||||||
|
fetchUnreadCount()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRealtime() {
|
||||||
|
if (realtimeUserId) {
|
||||||
|
echo.leave(`App.Models.User.${realtimeUserId}`)
|
||||||
|
realtimeUserId = null
|
||||||
|
privateChannel = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function markRead(id) {
|
async function markRead(id) {
|
||||||
const n = notifications.value.find(n => n.id === id)
|
const n = notifications.value.find(n => n.id === id)
|
||||||
if (n && !n.read_at) {
|
if (n && !n.read_at) {
|
||||||
@@ -110,6 +139,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
unreadCount, notifications, isOpen,
|
unreadCount, notifications, isOpen,
|
||||||
fetchNotifications, fetchUnreadCount,
|
fetchNotifications, fetchUnreadCount,
|
||||||
startPolling, stopPolling,
|
startPolling, stopPolling,
|
||||||
|
startRealtime, stopRealtime,
|
||||||
markRead, markAllRead, remove,
|
markRead, markAllRead, remove,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const route = useRoute()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const token = route.query.token
|
const token = new URLSearchParams(window.location.hash.substring(1)).get('token')
|
||||||
const error = route.query.error
|
const error = route.query.error
|
||||||
|
|
||||||
if (error || !token) {
|
if (error || !token) {
|
||||||
@@ -18,7 +18,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 存 token 先,再拉 profile
|
// 存 token 先,再拉 profile
|
||||||
localStorage.setItem('token', token)
|
sessionStorage.setItem('token', token)
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/member/profile')
|
const res = await api.get('/member/profile')
|
||||||
auth.setAuth(res.data.data, token)
|
auth.setAuth(res.data.data, token)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ async function submitBooking() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
|
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
|
||||||
>
|
>
|
||||||
<img :src="img.url" class="w-full h-full object-cover" />
|
<img :src="img.url" loading="lazy" class="w-full h-full object-cover" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const error = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const oauthError = route.query.error === 'oauth_failed'
|
const oauthError = route.query.error === 'oauth_failed'
|
||||||
? 'Google 登入失敗,請重試。'
|
? 'OAuth 授權失敗,請重新嘗試'
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const successMsg = route.query.registered ? '註冊成功,請登入。' : ''
|
const successMsg = route.query.registered ? '註冊成功,請登入。' : ''
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { getMyBookings, cancelBooking } from '../api/bookingApi'
|
import { getMyBookings, cancelBooking } from '../api/bookingApi'
|
||||||
|
import BookingChat from '../components/BookingChat.vue'
|
||||||
|
import { useBookingUnreadCounts } from '../composables/useBookingUnreadCounts'
|
||||||
|
import api from '../api/axios'
|
||||||
|
|
||||||
const bookings = ref([])
|
const bookings = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const expanded = ref(new Set())
|
const expanded = ref(new Set())
|
||||||
|
|
||||||
|
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(api)
|
||||||
|
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
|
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
|
||||||
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
|
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
|
||||||
@@ -26,6 +31,7 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
startPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggle(id) {
|
function toggle(id) {
|
||||||
@@ -84,6 +90,16 @@ function formatDate(dateStr) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<!-- 未讀訊息角標 -->
|
||||||
|
<span
|
||||||
|
v-if="(unreadCounts[b.id] ?? 0) > 0"
|
||||||
|
class="flex items-center gap-1 bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full leading-none"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
|
</svg>
|
||||||
|
{{ unreadCounts[b.id] }}
|
||||||
|
</span>
|
||||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||||
</span>
|
</span>
|
||||||
@@ -141,6 +157,15 @@ function formatDate(dateStr) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 即時訊息(confirmed / completed) -->
|
||||||
|
<BookingChat
|
||||||
|
v-if="b.status === 'confirmed' || b.status === 'completed'"
|
||||||
|
:bookingId="b.id"
|
||||||
|
:bookingStatus="b.status"
|
||||||
|
currentUserType="member"
|
||||||
|
@read="clearCount(b.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 操作按鈕列 -->
|
<!-- 操作按鈕列 -->
|
||||||
<div class="flex items-center justify-between pt-1">
|
<div class="flex items-center justify-between pt-1">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ import adminApi from '../../api/adminAxios'
|
|||||||
const providers = ref([])
|
const providers = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const search = ref('')
|
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() {
|
async function fetchProviders() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -12,12 +27,19 @@ async function fetchProviders() {
|
|||||||
const params = {}
|
const params = {}
|
||||||
if (search.value) params.q = search.value
|
if (search.value) params.q = search.value
|
||||||
const res = await adminApi.get('/admin/providers', { params })
|
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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePendingFilter() {
|
||||||
|
pendingOnly.value = !pendingOnly.value
|
||||||
|
fetchProviders()
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleActive(p) {
|
async function toggleActive(p) {
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`)
|
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 || '操作失敗') }
|
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleVerified(p) {
|
async function viewCertifications(p) {
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`)
|
const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } })
|
||||||
if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified
|
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 || '操作失敗') }
|
} 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" />
|
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" />
|
||||||
<button @click="fetchProviders"
|
<button @click="fetchProviders"
|
||||||
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
|
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
|
||||||
|
<button @click="togglePendingFilter"
|
||||||
|
:class="pendingOnly ? 'bg-amber-500 text-white' : 'bg-amber-50 text-amber-700'"
|
||||||
|
class="px-5 py-2 rounded-lg text-sm hover:opacity-80 transition">待審核</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
|
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
|
||||||
@@ -67,9 +114,8 @@ onMounted(fetchProviders)
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
|
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<span :class="p.provider_profile?.is_verified ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-500'"
|
<span :class="statusMeta(p).cls" class="text-xs px-2 py-1 rounded-full font-medium">
|
||||||
class="text-xs px-2 py-1 rounded-full font-medium">
|
{{ statusMeta(p).label }}
|
||||||
{{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
@@ -79,11 +125,18 @@ onMounted(fetchProviders)
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<div class="flex justify-center gap-2">
|
<div class="flex justify-center gap-2 flex-wrap">
|
||||||
<button @click="toggleVerified(p)"
|
<button @click="viewCertifications(p)"
|
||||||
:class="p.provider_profile?.is_verified ? 'bg-slate-100 text-slate-600' : 'bg-teal-50 text-teal-700'"
|
class="text-xs px-3 py-1 rounded-lg bg-slate-50 text-slate-600 hover:opacity-80 transition">
|
||||||
class="text-xs px-3 py-1 rounded-lg hover:opacity-80 transition">
|
證照
|
||||||
{{ p.provider_profile?.is_verified ? '取消驗證' : '驗證' }}
|
</button>
|
||||||
|
<button v-if="p.provider_profile?.verification_status === 'pending'" @click="approve(p)"
|
||||||
|
class="text-xs px-3 py-1 rounded-lg bg-teal-50 text-teal-700 hover:opacity-80 transition">
|
||||||
|
通過
|
||||||
|
</button>
|
||||||
|
<button v-if="['pending', 'approved'].includes(p.provider_profile?.verification_status)" @click="openReject(p)"
|
||||||
|
class="text-xs px-3 py-1 rounded-lg bg-rose-50 text-rose-600 hover:opacity-80 transition">
|
||||||
|
駁回
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleActive(p)"
|
<button @click="toggleActive(p)"
|
||||||
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
|
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
|
||||||
@@ -99,5 +152,34 @@ onMounted(fetchProviders)
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 證照檢視面板 -->
|
||||||
|
<div v-if="certsOf" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="certsOf = null">
|
||||||
|
<div class="bg-white rounded-2xl p-6 max-w-lg w-full mx-4">
|
||||||
|
<h2 class="font-semibold text-slate-800 mb-4">{{ certsOf.name }} 的證照</h2>
|
||||||
|
<p v-if="certsOf.certifications.length === 0" class="text-sm text-slate-400 py-6 text-center">尚未上傳證照</p>
|
||||||
|
<div v-else class="grid grid-cols-2 gap-3">
|
||||||
|
<a v-for="c in certsOf.certifications" :key="c.id" :href="c.url" target="_blank"
|
||||||
|
class="rounded-xl overflow-hidden border hover:opacity-90 transition">
|
||||||
|
<img :src="c.url" loading="lazy" class="w-full h-36 object-cover" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button @click="certsOf = null" class="mt-4 w-full bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">關閉</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 駁回原因面板 -->
|
||||||
|
<div v-if="rejectTarget" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="rejectTarget = null">
|
||||||
|
<div class="bg-white rounded-2xl p-6 max-w-md w-full mx-4">
|
||||||
|
<h2 class="font-semibold text-slate-800 mb-1">駁回 {{ rejectTarget.name }}</h2>
|
||||||
|
<p class="text-xs text-slate-400 mb-4">原因將以站內通知與 Email 告知教練</p>
|
||||||
|
<textarea v-model="rejectReason" rows="3" maxlength="500" placeholder="例如:證照影像不清晰,請重新拍攝上傳"
|
||||||
|
class="w-full border border-slate-300 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"></textarea>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button @click="rejectTarget = null" class="flex-1 bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">取消</button>
|
||||||
|
<button @click="confirmReject" class="flex-1 bg-rose-600 text-white py-2 rounded-xl text-sm hover:bg-rose-700 transition">確認駁回</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
|
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
|
||||||
|
import BookingChat from '../../components/BookingChat.vue'
|
||||||
|
import { useBookingUnreadCounts } from '../../composables/useBookingUnreadCounts'
|
||||||
|
import coachApi from '../../api/coachAxios'
|
||||||
|
|
||||||
const bookings = ref([])
|
const bookings = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -31,8 +34,23 @@ const groupedByOffer = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
|
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
|
||||||
|
const chatExpanded = ref(new Set())
|
||||||
|
|
||||||
onMounted(fetchBookings)
|
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(coachApi)
|
||||||
|
|
||||||
|
function toggleChat(id) {
|
||||||
|
if (chatExpanded.value.has(id)) chatExpanded.value.delete(id)
|
||||||
|
else chatExpanded.value.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canChat(status) {
|
||||||
|
return status === 'confirmed' || status === 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchBookings()
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
async function fetchBookings() {
|
async function fetchBookings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -88,9 +106,10 @@ async function doAction(booking, action) {
|
|||||||
<div
|
<div
|
||||||
v-for="b in group"
|
v-for="b in group"
|
||||||
:key="b.id"
|
:key="b.id"
|
||||||
class="bg-white rounded-xl border px-5 py-4 flex items-start justify-between flex-wrap gap-3"
|
class="bg-white rounded-xl border overflow-hidden"
|
||||||
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
|
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
|
||||||
>
|
>
|
||||||
|
<div class="px-5 py-4 flex items-start justify-between flex-wrap gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-gray-700">
|
<p class="text-sm font-medium text-gray-700">
|
||||||
{{ b.scheduled_date }} {{ b.start_time }}
|
{{ b.scheduled_date }} {{ b.start_time }}
|
||||||
@@ -107,7 +126,7 @@ async function doAction(booking, action) {
|
|||||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 flex-wrap justify-end">
|
||||||
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
|
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
|
||||||
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
|
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
|
||||||
確認
|
確認
|
||||||
@@ -124,9 +143,33 @@ async function doAction(booking, action) {
|
|||||||
class="text-xs text-orange-500 hover:text-orange-700 underline">
|
class="text-xs text-orange-500 hover:text-orange-700 underline">
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="canChat(b.status)" @click="toggleChat(b.id)"
|
||||||
|
class="relative text-xs border px-3 py-1 rounded-full transition"
|
||||||
|
:class="chatExpanded.has(b.id)
|
||||||
|
? 'border-blue-400 text-blue-600'
|
||||||
|
: 'border-gray-300 hover:border-blue-400 hover:text-blue-600 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ chatExpanded.has(b.id) ? '收起訊息' : '訊息' }}
|
||||||
|
<!-- 未讀紅點 -->
|
||||||
|
<span
|
||||||
|
v-if="(unreadCounts[b.id] ?? 0) > 0 && !chatExpanded.has(b.id)"
|
||||||
|
class="absolute -top-1 -right-1 min-w-[1rem] h-4 flex items-center justify-center bg-red-500 text-white text-[9px] font-bold rounded-full px-0.5"
|
||||||
|
>{{ unreadCounts[b.id] }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 即時訊息(confirmed / completed,點擊展開) -->
|
||||||
|
<div v-if="canChat(b.status) && chatExpanded.has(b.id)" class="border-t border-gray-100 p-4">
|
||||||
|
<BookingChat
|
||||||
|
:bookingId="b.id"
|
||||||
|
:bookingStatus="b.status"
|
||||||
|
currentUserType="provider"
|
||||||
|
@read="clearCount(b.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user