Compare commits
18 Commits
75462c2619
...
cc010b5c83
| Author | SHA1 | Date | |
|---|---|---|---|
| cc010b5c83 | |||
| c10d4ebfe6 | |||
| 153aee5f94 | |||
| f996a0053b | |||
| e7776fa930 | |||
| 972829419b | |||
| ecfb268d61 | |||
| d4821e527b | |||
| 12bd854dce | |||
| 250c50fc74 | |||
| ff2484a48f | |||
| 856c342757 | |||
| 56dbca2e11 | |||
| 8c54808cd8 | |||
| 3ddad32129 | |||
| 0dda2bb2a3 | |||
| b475a61d14 | |||
| 4aab94e07a |
@@ -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
|
||||
@@ -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
|
||||
+2
-1
@@ -18,7 +18,8 @@ yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
/.claude
|
||||
/.claude/settings.local.json
|
||||
/.claude/worktrees/
|
||||
/.opensp
|
||||
|
||||
# Frontend
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# CLAUDE.MD — 全域開發規則
|
||||
|
||||
## 語言設定
|
||||
|
||||
所有回應、說明、註解一律使用**繁體中文**。
|
||||
程式碼內的變數名稱、函式名稱維持英文。
|
||||
|
||||
---
|
||||
|
||||
## 技術棧預設
|
||||
|
||||
- **後端**:PHP 8.x / Laravel 11.44.7(主力)
|
||||
- **前端**:Vue 3 / React
|
||||
- **DB**:MySQL 8.0(port 3306,透過 Docker 容器運行)
|
||||
- **IDE**:Windsurf
|
||||
|
||||
---
|
||||
|
||||
## 禁止行為
|
||||
|
||||
- 禁止在未確認 spec 對應關係的情況下開始實作
|
||||
- 禁止使用 raw SQL query(除非有明確說明理由)
|
||||
- 禁止在前端直接使用 `innerHTML` 渲染未經處理的使用者輸入
|
||||
|
||||
---
|
||||
|
||||
## 安全強制規則(涉及以下情境時必須執行)
|
||||
|
||||
**觸發條件**:task 涉及以下任一項時,實作前必須先回答所有問題,未回答完畢不得開始實作。
|
||||
|
||||
### 觸發條件清單
|
||||
|
||||
- 接受外部輸入(表單、API 參數、URL query)
|
||||
- 資料庫讀寫操作
|
||||
- 身份驗證 / 授權邏輯
|
||||
- 檔案上傳或處理
|
||||
- 呼叫外部 API / Webhook
|
||||
|
||||
### 安全確認問題(觸發後必答)
|
||||
|
||||
**輸入驗證**
|
||||
- 外部輸入有沒有經過 Validation?在哪一層?
|
||||
- 有沒有直接拼接進 SQL 或 shell 指令?
|
||||
|
||||
**資料庫**
|
||||
- 是否使用 Query Builder 或 Eloquent ORM?(禁止使用 raw query,除非有明確理由)
|
||||
- 有沒有 N+1 查詢風險?
|
||||
|
||||
**權限**
|
||||
- 這個操作需要什麼權限?權限檢查在哪一層執行?
|
||||
- 有沒有可能被未授權使用者觸發?
|
||||
|
||||
**前端(Vue/React)**
|
||||
- 有沒有直接將使用者輸入渲染進 DOM?(XSS 風險)
|
||||
- API 呼叫有沒有帶正確的 Auth Header?
|
||||
|
||||
### 機敏資訊管理
|
||||
- 密碼、API Key 等一律放 `.env`,不進版本控制
|
||||
- 設定檔使用 `${VAR}` 引用環境變數,實際值由 `.env` 注入
|
||||
|
||||
---
|
||||
## 命名原則
|
||||
|
||||
- Function 名必須是動詞片語,且能完整描述其行為(`getUserById` 而非 `getUser`)
|
||||
- Boolean 變數 / 屬性以 `is` / `has` / `can` 開頭(`isActive`、`hasPermission`)
|
||||
- 禁止使用縮寫(`usr`、`btn`、`tmp`),除非是業界通用(`id`、`url`、`api`)
|
||||
- Class 名是名詞,不帶 `Manager` / `Helper` / `Utils` 等空洞字尾——若需要,代表職責未釐清
|
||||
|
||||
---
|
||||
|
||||
## Function 設計原則
|
||||
|
||||
- 單一職責:一個 function 只做一件事,能用一句話描述
|
||||
- 長度警戒線:超過 20 行必須說明理由,或主動提示拆分
|
||||
- 參數上限:超過 3 個參數時,考慮封裝為 DTO(Data Transfer Object)
|
||||
|
||||
---
|
||||
|
||||
## 註解原則
|
||||
|
||||
- 好的命名取代大部分的註解——若需要大量解釋,先重構命名
|
||||
- 禁止「描述行為」的註解(`// 遍歷陣列`),只允許「解釋為什麼」的註解(`// 此處使用 X 而非 Y,因為...`)
|
||||
- TODO 必須帶 issue 連結或日期,裸露的 TODO 視為技術債
|
||||
|
||||
---
|
||||
|
||||
## SOLID 原則
|
||||
|
||||
| 原則 | 實踐方式 |
|
||||
|------|---------|
|
||||
| **S** 單一職責 | 每個 Controller 只處理一個資源;業務邏輯抽到 Service |
|
||||
| **O** 開放封閉 | 新增功能透過擴充(新 Class / 新方法),不修改既有邏輯 |
|
||||
| **L** 里氏替換 | 子類別可完全替換父類別,不破壞預期行為 |
|
||||
| **I** 介面隔離 | 不強迫實作用不到的方法;介面依使用情境拆細 |
|
||||
| **D** 依賴反轉 | 依賴抽象(介面 / Contract)而非具體實作;透過 Laravel DI Container 注入 |
|
||||
|
||||
### OOP 規範
|
||||
- **封裝**:內部狀態不直接暴露,透過方法存取
|
||||
- **繼承**:優先組合(Trait / Composition)而非繼承;繼承層數不超過 2 層
|
||||
- **多型**:相同介面的不同實作透過 binding 切換,不用 `if/switch` 判斷型別
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec 工作流規則
|
||||
|
||||
### Task 執行前
|
||||
|
||||
每次執行 task 前,先確認:
|
||||
|
||||
1. 這個 task 對應哪一條 spec?(必須能指向具體文件與段落)
|
||||
2. Acceptance Criteria 是否已定義且可測量?
|
||||
3. 這個 task 完成後,哪些 spec 文件需要同步更新?
|
||||
|
||||
### Task 執行後
|
||||
|
||||
完成任何 task 後,主動告知:
|
||||
|
||||
- 實作結果是否與 spec 描述一致
|
||||
- 如有偏差,列出偏差點並詢問是否更新 spec
|
||||
- **必須告知 spec 同步狀態**:這個改動有沒有影響現有 spec 描述的行為?需要更新哪個文件?
|
||||
|
||||
---
|
||||
|
||||
## 維護性規則(所有 task 適用,非強制阻擋,但必須提醒)
|
||||
|
||||
每次 task 完成後,主動告知以下項目的狀態:
|
||||
|
||||
- **重複邏輯**:這段邏輯是否已存在於 codebase 中,應該抽共用?
|
||||
- **命名一致性**:變數、函式、API endpoint 命名是否與現有規範一致?
|
||||
|
||||
---
|
||||
|
||||
## 溝通風格
|
||||
|
||||
- 發現問題直接說,不要繞彎
|
||||
- 有不確定的地方,明確標注「我不確定,需要你確認」
|
||||
- 不要重複確認已經決定的技術選擇
|
||||
@@ -1,4 +1,49 @@
|
||||

|
||||

|
||||

|
||||
# CFDive Platform
|
||||
|
||||
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。
|
||||
|
||||
---
|
||||
|
||||
## 功能概覽
|
||||
|
||||
**會員(Member)**
|
||||
- 註冊 / 登入(Email + Google OAuth)
|
||||
- 瀏覽、搜尋、篩選潛水課程
|
||||
- 查看課程時段並送出預約
|
||||
- 對完成的課程留下評價(支援匿名、有幫助投票)
|
||||
- 站內通知(Email + Polling)
|
||||
|
||||
**教練(Provider)**
|
||||
- 課程 CRUD(含封面 + 相簿圖片上傳)
|
||||
- 課程時段管理
|
||||
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||
|
||||
**管理員(Admin)**
|
||||
- 平台統計數據(會員數、教練數、課程數)
|
||||
- 會員與教練帳號管理(啟用 / 停用 / 審核)
|
||||
- 課程、預約、評價管理
|
||||
|
||||
---
|
||||
|
||||
## 技術棧
|
||||
|
||||
| 層級 | 技術 |
|
||||
|------|------|
|
||||
| 後端 | PHP 8.x / Laravel 11 |
|
||||
| 前端 | Vue 3 |
|
||||
| 資料庫 | MySQL 8.0 |
|
||||
| 快取 | Redis(predis)|
|
||||
| 認證 | Laravel Sanctum + Google OAuth |
|
||||
| 容器 | Docker / Docker Compose |
|
||||
| API 文件 | Swagger UI(l5-swagger)|
|
||||
|
||||
---
|
||||
|
||||
## API 文件
|
||||
|
||||
共 73 個端點,涵蓋:
|
||||
- 認證(Email + Google OAuth)
|
||||
- 公開課程查詢
|
||||
- 會員預約 / 評價 / 通知
|
||||
- 教練課程 / 時段 / 預約管理
|
||||
- 管理員後台
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<?php
|
||||
|
||||
namespace App\Docs;
|
||||
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Admin 統計",
|
||||
* description="管理員平台統計"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Admin 會員管理",
|
||||
* description="管理員的會員帳號管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Admin 教練管理",
|
||||
* description="管理員的服務提供者帳號管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Admin 課程管理",
|
||||
* description="管理員的課程、預約、評價管理"
|
||||
* )
|
||||
*/
|
||||
class AdminApiDoc
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin Stats
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得平台統計數據
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/stats",
|
||||
* summary="取得平台統計數據",
|
||||
* description="回傳會員總數、服務提供者總數、課程總數;非 admin 角色回傳 403",
|
||||
* operationId="getAdminStats",
|
||||
* tags={"Admin 統計"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="total_members", type="integer", example=128),
|
||||
* @OA\Property(property="total_providers", type="integer", example=34),
|
||||
* @OA\Property(property="total_offers", type="integer", example=87)
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getAdminStats()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin Member Management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得會員列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/members",
|
||||
* summary="取得會員列表",
|
||||
* description="分頁回傳所有會員帳號,含 member_profile",
|
||||
* operationId="listAdminMembers",
|
||||
* tags={"Admin 會員管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="王小明"),
|
||||
* @OA\Property(property="email", type="string", example="member@example.com"),
|
||||
* @OA\Property(property="role", type="string", example="member"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||
* @OA\Property(property="member_profile", type="object", nullable=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listAdminMembers()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一會員
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/members/{id}",
|
||||
* summary="取得單一會員",
|
||||
* operationId="getAdminMember",
|
||||
* tags={"Admin 會員管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="object")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getAdminMember()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換會員啟用狀態
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/members/{id}/toggle-active",
|
||||
* summary="切換會員啟用狀態",
|
||||
* description="啟用或停用指定會員帳號",
|
||||
* operationId="toggleMemberActive",
|
||||
* tags={"Admin 會員管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="切換成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="帳號已停用"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=false)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function toggleMemberActive()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認會員存在
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/check-member/{id}",
|
||||
* summary="確認會員存在",
|
||||
* description="快速確認指定 ID 的會員是否存在(角色為 member)",
|
||||
* operationId="checkMember",
|
||||
* tags={"Admin 會員管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="會員存在",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="exists", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function checkMember()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin Provider Management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得教練列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/providers",
|
||||
* summary="取得教練列表",
|
||||
* description="分頁回傳所有服務提供者,含 provider_profile",
|
||||
* operationId="listAdminProviders",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=2),
|
||||
* @OA\Property(property="name", type="string", example="林教練"),
|
||||
* @OA\Property(property="email", type="string", example="coach@example.com"),
|
||||
* @OA\Property(property="role", type="string", example="provider"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="is_verified", type="boolean", example=false),
|
||||
* @OA\Property(property="provider_profile", type="object", nullable=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listAdminProviders()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一教練
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/providers/{id}",
|
||||
* summary="取得單一教練",
|
||||
* operationId="getAdminProvider",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="object")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getAdminProvider()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換教練啟用狀態
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/providers/{id}/toggle-active",
|
||||
* summary="切換教練啟用狀態",
|
||||
* operationId="toggleProviderActive",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="切換成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="帳號已停用"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=false)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function toggleProviderActive()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換教練審核狀態
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/providers/{id}/toggle-verified",
|
||||
* summary="切換教練審核狀態",
|
||||
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
|
||||
* operationId="toggleProviderVerified",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="切換成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="教練已通過審核"),
|
||||
* @OA\Property(property="is_verified", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function toggleProviderVerified()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認教練存在
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/check-provider/{id}",
|
||||
* summary="確認教練存在",
|
||||
* operationId="checkProvider",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="查詢成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="exists", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function checkProvider()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin Offers / Bookings / Reviews
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得所有課程(Admin)
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/offers",
|
||||
* summary="取得所有課程(Admin)",
|
||||
* description="分頁回傳全平台課程列表",
|
||||
* operationId="listAdminOffers",
|
||||
* tags={"Admin 課程管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listAdminOffers()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除課程(Admin)
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/admin/offers/{id}",
|
||||
* summary="刪除課程(Admin)",
|
||||
* operationId="deleteAdminOffer",
|
||||
* tags={"Admin 課程管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteAdminOffer()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有預約(Admin)
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/bookings",
|
||||
* summary="取得所有預約(Admin)",
|
||||
* description="分頁回傳全平台預約列表",
|
||||
* operationId="listAdminBookings",
|
||||
* tags={"Admin 課程管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listAdminBookings()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記預約完成(Admin)
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/bookings/{id}/complete",
|
||||
* summary="標記預約完成(Admin)",
|
||||
* operationId="completeBookingByAdmin",
|
||||
* tags={"Admin 課程管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function completeBookingByAdmin()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有評價(Admin)
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/admin/reviews",
|
||||
* summary="取得所有評價(Admin)",
|
||||
* description="分頁回傳全平台評價列表,per_page 最大 100",
|
||||
* operationId="listAdminReviews",
|
||||
* tags={"Admin 課程管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(最大 100)", @OA\Schema(type="integer", default=20, maximum=100)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Review")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listAdminReviews()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除評價(Admin)
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/admin/reviews/{id}",
|
||||
* summary="刪除評價(Admin)",
|
||||
* operationId="deleteAdminReview",
|
||||
* tags={"Admin 課程管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteAdminReview()
|
||||
{
|
||||
}
|
||||
}
|
||||
+92
-21
@@ -35,6 +35,10 @@ use OpenApi\Annotations as OA;
|
||||
* name="管理員",
|
||||
* description="管理員相關操作"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="認證",
|
||||
* description="通用認證操作(登出、取得當前使用者)"
|
||||
* )
|
||||
*/
|
||||
class AuthApiDoc
|
||||
{
|
||||
@@ -42,7 +46,7 @@ class AuthApiDoc
|
||||
* 會員註冊
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/register/member",
|
||||
* path="/member/register",
|
||||
* summary="會員註冊",
|
||||
* description="建立新的會員帳號",
|
||||
* operationId="registerMember",
|
||||
@@ -119,7 +123,7 @@ class AuthApiDoc
|
||||
* 會員登入
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/login/member",
|
||||
* path="/member/login",
|
||||
* summary="會員登入",
|
||||
* description="會員帳號登入系統",
|
||||
* operationId="loginMember",
|
||||
@@ -207,7 +211,7 @@ class AuthApiDoc
|
||||
* 會員登出
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/logout/member",
|
||||
* path="/member/logout",
|
||||
* summary="會員登出",
|
||||
* description="會員登出系統並撤銷當前令牌",
|
||||
* operationId="logoutMember",
|
||||
@@ -241,7 +245,7 @@ class AuthApiDoc
|
||||
* 取得會員個人資料
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/profile/member",
|
||||
* path="/member/profile",
|
||||
* summary="取得會員個人資料",
|
||||
* description="取得當前登入會員的個人資料",
|
||||
* operationId="memberProfile",
|
||||
@@ -303,7 +307,7 @@ class AuthApiDoc
|
||||
* 更新會員個人資料
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/profile/member",
|
||||
* path="/member/profile",
|
||||
* summary="更新會員個人資料",
|
||||
* description="更新當前登入會員的個人資料",
|
||||
* operationId="updateMemberProfile",
|
||||
@@ -383,8 +387,8 @@ class AuthApiDoc
|
||||
/**
|
||||
* 修改會員密碼
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/password/member",
|
||||
* @OA\Put(
|
||||
* path="/member/change-password",
|
||||
* summary="修改會員密碼",
|
||||
* description="修改當前登入會員的密碼",
|
||||
* operationId="changeMemberPassword",
|
||||
@@ -444,7 +448,7 @@ class AuthApiDoc
|
||||
* 服務提供者註冊
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/register/provider",
|
||||
* path="/provider/register",
|
||||
* summary="服務提供者註冊",
|
||||
* description="建立新的服務提供者帳號",
|
||||
* operationId="registerProvider",
|
||||
@@ -498,7 +502,7 @@ class AuthApiDoc
|
||||
* 服務提供者登入
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/login/provider",
|
||||
* path="/provider/login",
|
||||
* summary="服務提供者登入",
|
||||
* description="服務提供者帳號登入系統",
|
||||
* operationId="loginProvider",
|
||||
@@ -580,7 +584,7 @@ class AuthApiDoc
|
||||
* 服務提供者登出
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/logout/provider",
|
||||
* path="/provider/logout",
|
||||
* summary="服務提供者登出",
|
||||
* description="服務提供者登出系統並撤銷當前令牌",
|
||||
* operationId="logoutProvider",
|
||||
@@ -614,7 +618,7 @@ class AuthApiDoc
|
||||
* 取得服務提供者資料
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/profile/provider",
|
||||
* path="/provider/profile",
|
||||
* summary="取得服務提供者資料",
|
||||
* description="取得當前登入服務提供者的資料",
|
||||
* operationId="providerProfile",
|
||||
@@ -678,7 +682,7 @@ class AuthApiDoc
|
||||
* 更新服務提供者資料
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/profile/provider",
|
||||
* path="/provider/profile",
|
||||
* summary="更新服務提供者資料",
|
||||
* description="更新當前登入服務提供者的資料",
|
||||
* operationId="updateProviderProfile",
|
||||
@@ -764,8 +768,8 @@ class AuthApiDoc
|
||||
/**
|
||||
* 修改服務提供者密碼
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/password/provider",
|
||||
* @OA\Put(
|
||||
* path="/provider/change-password",
|
||||
* summary="修改服務提供者密碼",
|
||||
* description="修改當前登入服務提供者的密碼",
|
||||
* operationId="changeProviderPassword",
|
||||
@@ -825,7 +829,7 @@ class AuthApiDoc
|
||||
* 管理員註冊
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/register/admin",
|
||||
* path="/admin/register",
|
||||
* summary="管理員註冊",
|
||||
* description="建立新的管理員帳號",
|
||||
* operationId="registerAdmin",
|
||||
@@ -877,7 +881,7 @@ class AuthApiDoc
|
||||
* 管理員登入
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/login/admin",
|
||||
* path="/admin/login",
|
||||
* summary="管理員登入",
|
||||
* description="管理員帳號登入系統",
|
||||
* operationId="loginAdmin",
|
||||
@@ -958,7 +962,7 @@ class AuthApiDoc
|
||||
* 管理員登出
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/logout/admin",
|
||||
* path="/admin/logout",
|
||||
* summary="管理員登出",
|
||||
* description="管理員登出系統並撤銷當前令牌",
|
||||
* operationId="logoutAdmin",
|
||||
@@ -992,7 +996,7 @@ class AuthApiDoc
|
||||
* 取得管理員個人資料
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/profile/admin",
|
||||
* path="/admin/profile",
|
||||
* summary="取得管理員個人資料",
|
||||
* description="取得當前登入管理員的個人資料",
|
||||
* operationId="adminProfile",
|
||||
@@ -1030,7 +1034,7 @@ class AuthApiDoc
|
||||
* 更新管理員個人資料
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/profile/admin",
|
||||
* path="/admin/profile",
|
||||
* summary="更新管理員個人資料",
|
||||
* description="更新當前登入管理員的個人資料",
|
||||
* operationId="updateAdminProfile",
|
||||
@@ -1087,8 +1091,8 @@ class AuthApiDoc
|
||||
/**
|
||||
* 修改管理員密碼
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/password/admin",
|
||||
* @OA\Put(
|
||||
* path="/admin/change-password",
|
||||
* summary="修改管理員密碼",
|
||||
* description="修改當前登入管理員的密碼",
|
||||
* operationId="changeAdminPassword",
|
||||
@@ -1143,4 +1147,71 @@ class AuthApiDoc
|
||||
public function changeAdminPassword()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用登出
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/logout",
|
||||
* summary="通用登出",
|
||||
* description="撤銷當前 Bearer token,適用所有角色",
|
||||
* operationId="logout",
|
||||
* tags={"認證"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="登出成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="登出成功")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="未認證",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前使用者
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/user",
|
||||
* summary="取得當前使用者",
|
||||
* description="回傳當前 Bearer token 所屬的使用者資訊",
|
||||
* operationId="currentUser",
|
||||
* tags={"認證"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="王小明"),
|
||||
* @OA\Property(property="email", type="string", example="user@example.com"),
|
||||
* @OA\Property(property="role", type="string", enum={"member","provider","admin"}, example="member"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="未認證",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function currentUser()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Docs;
|
||||
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Google OAuth",
|
||||
* description="Google OAuth 2.0 社群登入"
|
||||
* )
|
||||
*/
|
||||
class AuthSupplementDoc
|
||||
{
|
||||
/**
|
||||
* 取得 Google OAuth 重定向 URL
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/auth/google/redirect",
|
||||
* summary="取得 Google OAuth 重定向 URL",
|
||||
* description="回傳 Google OAuth 授權頁面的 redirect_url,前端應將使用者導向此 URL 以啟動 OAuth 流程",
|
||||
* operationId="googleRedirect",
|
||||
* tags={"Google OAuth"},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得 redirect_url 成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="redirect_url", type="string", format="uri", example="https://accounts.google.com/o/oauth2/auth?client_id=...")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function googleRedirect()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth 回調
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/auth/google/callback",
|
||||
* summary="Google OAuth 回調",
|
||||
* description="Google OAuth 授權完成後的回調端點,通常由瀏覽器自動呼叫。成功後回傳 Bearer token 與使用者資訊",
|
||||
* operationId="googleCallback",
|
||||
* tags={"Google OAuth"},
|
||||
* @OA\Parameter(
|
||||
* name="code",
|
||||
* in="query",
|
||||
* required=true,
|
||||
* description="Google 授權碼",
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="state",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="OAuth state 參數",
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="OAuth 登入成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="登入成功"),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer"),
|
||||
* @OA\Property(
|
||||
* property="user",
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="王小明"),
|
||||
* @OA\Property(property="email", type="string", example="user@gmail.com"),
|
||||
* @OA\Property(property="role", type="string", example="member"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="OAuth 驗證失敗",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=false),
|
||||
* @OA\Property(property="message", type="string", example="OAuth 驗證失敗")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function googleCallback()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
namespace App\Docs;
|
||||
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="會員預約",
|
||||
* description="會員的預約管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="會員評價",
|
||||
* description="會員的評價管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="通知",
|
||||
* description="站內通知管理(Member / Provider 共用)"
|
||||
* )
|
||||
*/
|
||||
class MemberApiDoc
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Member Bookings
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 建立預約
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/member/bookings",
|
||||
* summary="建立預約",
|
||||
* description="會員建立新的課程預約",
|
||||
* operationId="createBooking",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"offer_id","schedule_id","participants"},
|
||||
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
|
||||
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
|
||||
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
|
||||
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="預約建立成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約成功"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="驗證失敗或時段已滿",
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="無權限(非 member 角色)",
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function createBooking()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得我的預約列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/member/bookings",
|
||||
* summary="取得我的預約列表",
|
||||
* description="分頁回傳當前會員的所有預約",
|
||||
* operationId="listMemberBookings",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listMemberBookings()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一預約
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/member/bookings/{id}",
|
||||
* summary="取得單一預約",
|
||||
* operationId="getMemberBooking",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getMemberBooking()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消預約
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/member/bookings/{id}",
|
||||
* summary="取消預約",
|
||||
* description="會員取消自己的預約",
|
||||
* operationId="cancelBooking",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取消成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已取消")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cancelBooking()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Member Reviews
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 建立評價
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/member/reviews",
|
||||
* summary="建立評價",
|
||||
* description="會員對已完成的預約課程提交評價",
|
||||
* operationId="createReview",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"booking_id","rating"},
|
||||
* @OA\Property(property="booking_id", type="integer", example=5),
|
||||
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
|
||||
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
|
||||
* @OA\Property(property="is_anonymous", type="boolean", example=false)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="評價建立成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="評價已提交"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function createReview()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新評價
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/member/reviews/{id}",
|
||||
* summary="更新評價",
|
||||
* operationId="updateReview",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
|
||||
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
|
||||
* @OA\Property(property="is_anonymous", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="更新成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateReview()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除評價
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/member/reviews/{id}",
|
||||
* summary="刪除評價",
|
||||
* operationId="deleteReview",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteReview()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記評價為有幫助
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/reviews/{id}/helpful",
|
||||
* summary="標記評價為有幫助",
|
||||
* description="切換投票狀態(已投票則撤回)",
|
||||
* operationId="markReviewHelpful",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="操作成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="helpful_count", type="integer", example=4),
|
||||
* @OA\Property(property="has_voted", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function markReviewHelpful()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Notifications (Member + Provider 共用)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得通知列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/notifications",
|
||||
* summary="取得通知列表",
|
||||
* description="分頁回傳當前使用者的所有通知(Member / Provider 共用)",
|
||||
* operationId="listNotifications",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="type", type="string", example="booking_confirmed"),
|
||||
* @OA\Property(property="title", type="string", example="預約已確認"),
|
||||
* @OA\Property(property="message", type="string", example="您的預約已由教練確認"),
|
||||
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listNotifications()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得未讀通知數量
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/notifications/unread-count",
|
||||
* summary="取得未讀通知數量",
|
||||
* operationId="getUnreadNotificationCount",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="unread_count", type="integer", example=3)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function getUnreadNotificationCount()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記單一通知為已讀
|
||||
*
|
||||
* @OA\Patch(
|
||||
* path="/notifications/{id}/read",
|
||||
* summary="標記單一通知為已讀",
|
||||
* operationId="markNotificationRead",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function markNotificationRead()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部通知標記為已讀
|
||||
*
|
||||
* @OA\Patch(
|
||||
* path="/notifications/read-all",
|
||||
* summary="全部通知標記為已讀",
|
||||
* operationId="markAllNotificationsRead",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function markAllNotificationsRead()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除通知
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/notifications/{id}",
|
||||
* summary="刪除通知",
|
||||
* operationId="deleteNotification",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="通知已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteNotification()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
<?php
|
||||
|
||||
namespace App\Docs;
|
||||
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="教練課程",
|
||||
* description="服務提供者的課程管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="教練圖片",
|
||||
* description="服務提供者的課程圖片管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="教練時段",
|
||||
* description="服務提供者的課程時段管理"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="教練預約",
|
||||
* description="服務提供者的預約管理"
|
||||
* )
|
||||
*/
|
||||
class ProviderApiDoc
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Offers CRUD
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得自己的課程列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/provider/offers",
|
||||
* summary="取得自己的課程列表",
|
||||
* description="回傳當前服務提供者的所有課程(分頁)",
|
||||
* operationId="listProviderOffers",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listProviderOffers()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立課程
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/provider/offers",
|
||||
* summary="建立課程",
|
||||
* description="服務提供者建立新的潛水課程",
|
||||
* operationId="createProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"title","description","region","price","max_participants"},
|
||||
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
|
||||
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
|
||||
* @OA\Property(property="region", type="string", example="墾丁"),
|
||||
* @OA\Property(property="price", type="number", format="float", example=8500),
|
||||
* @OA\Property(property="max_participants", type="integer", example=6),
|
||||
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="課程建立成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="課程建立成功"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function createProviderOffer()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一課程(Provider 視角)
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/provider/offers/{id}",
|
||||
* summary="取得單一課程(Provider 視角)",
|
||||
* description="取得自己的指定課程,非本人課程回傳 403",
|
||||
* operationId="getProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getProviderOffer()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新課程
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/provider/offers/{id}",
|
||||
* summary="更新課程",
|
||||
* operationId="updateProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
|
||||
* @OA\Property(property="description", type="string", example="AOWD 課程"),
|
||||
* @OA\Property(property="region", type="string", example="小琉球"),
|
||||
* @OA\Property(property="price", type="number", format="float", example=12000),
|
||||
* @OA\Property(property="max_participants", type="integer", example=4),
|
||||
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"進階","AOWD"}),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="更新成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateProviderOffer()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除課程
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/provider/offers/{id}",
|
||||
* summary="刪除課程",
|
||||
* operationId="deleteProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteProviderOffer()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Offer Images
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 上傳封面圖片
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/provider/offers/{id}/cover",
|
||||
* summary="上傳封面圖片",
|
||||
* description="上傳課程封面圖片(multipart/form-data)",
|
||||
* operationId="uploadOfferCover",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"cover_image"},
|
||||
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB)")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="上傳成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function uploadOfferCover()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除封面圖片
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/provider/offers/{id}/cover",
|
||||
* summary="刪除封面圖片",
|
||||
* operationId="deleteOfferCover",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteOfferCover()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 上傳相簿圖片
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/provider/offers/{id}/images",
|
||||
* summary="上傳相簿圖片",
|
||||
* description="新增課程相簿圖片(multipart/form-data,可多張)",
|
||||
* operationId="uploadOfferImages",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"images[]"},
|
||||
* @OA\Property(
|
||||
* property="images[]",
|
||||
* type="array",
|
||||
* @OA\Items(type="string", format="binary"),
|
||||
* description="相簿圖片(每張最大 5MB)"
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="上傳成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function uploadOfferImages()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除相簿圖片
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/provider/images/{id}",
|
||||
* summary="刪除相簿圖片",
|
||||
* operationId="deleteOfferImage",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="圖片已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteOfferImage()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Schedules
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得時段列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/provider/schedules",
|
||||
* summary="取得時段列表",
|
||||
* description="回傳服務提供者的所有時段,可依 offer_id 篩選",
|
||||
* operationId="listProviderSchedules",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listProviderSchedules()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立時段
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/provider/schedules",
|
||||
* summary="建立時段",
|
||||
* operationId="createProviderSchedule",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"offer_id","start_date","end_date","available_slots"},
|
||||
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
|
||||
* @OA\Property(property="available_slots", type="integer", example=4)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="時段建立成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function createProviderSchedule()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新時段
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/provider/schedules/{id}",
|
||||
* summary="更新時段",
|
||||
* operationId="updateProviderSchedule",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
|
||||
* @OA\Property(property="available_slots", type="integer", example=6),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="更新成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateProviderSchedule()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除時段
|
||||
*
|
||||
* @OA\Delete(
|
||||
* path="/provider/schedules/{id}",
|
||||
* summary="刪除時段",
|
||||
* operationId="deleteProviderSchedule",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="時段已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteProviderSchedule()
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Bookings Management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 取得收到的預約列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/provider/bookings",
|
||||
* summary="取得收到的預約列表",
|
||||
* description="分頁回傳服務提供者收到的所有預約",
|
||||
* operationId="listProviderBookings",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listProviderBookings()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認預約
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/provider/bookings/{id}/confirm",
|
||||
* summary="確認預約",
|
||||
* operationId="confirmBooking",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="確認成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已確認"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function confirmBooking()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒絕預約
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/provider/bookings/{id}/reject",
|
||||
* summary="拒絕預約",
|
||||
* operationId="rejectBooking",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="拒絕成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已拒絕"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function rejectBooking()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 標記預約完成
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/provider/bookings/{id}/complete",
|
||||
* summary="標記預約完成",
|
||||
* operationId="completeBookingByProvider",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function completeBookingByProvider()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消預約(Provider)
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/provider/bookings/{id}/cancel",
|
||||
* summary="取消預約(Provider)",
|
||||
* operationId="cancelBookingByProvider",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取消成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已取消"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cancelBookingByProvider()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Docs;
|
||||
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="公開課程",
|
||||
* description="無需認證的公開潛水課程查詢端點"
|
||||
* )
|
||||
*
|
||||
* -----------------------------------------------------------------------
|
||||
* 共用 Schema 定義
|
||||
* -----------------------------------------------------------------------
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="PaginationMeta",
|
||||
* type="object",
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="last_page", type="integer", example=5),
|
||||
* @OA\Property(property="per_page", type="integer", example=15),
|
||||
* @OA\Property(property="total", type="integer", example=72)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ApiErrorResponse",
|
||||
* type="object",
|
||||
* @OA\Property(property="status", type="boolean", example=false),
|
||||
* @OA\Property(property="message", type="string", example="操作失敗"),
|
||||
* @OA\Property(property="errors", type="object", nullable=true)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DivingOffer",
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="provider_id", type="integer", example=3),
|
||||
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
|
||||
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
|
||||
* @OA\Property(property="region", type="string", example="墾丁"),
|
||||
* @OA\Property(property="price", type="number", format="float", example=8500),
|
||||
* @OA\Property(property="max_participants", type="integer", example=6),
|
||||
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"}),
|
||||
* @OA\Property(property="cover_image_url", type="string", nullable=true, example="https://example.com/image.jpg"),
|
||||
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={}),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Review",
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
|
||||
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
|
||||
* @OA\Property(property="is_anonymous", type="boolean", example=false),
|
||||
* @OA\Property(property="helpful_count", type="integer", example=3),
|
||||
* @OA\Property(property="has_voted", type="boolean", example=false),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CourseSchedule",
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
|
||||
* @OA\Property(property="available_slots", type="integer", example=4),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Booking",
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="member_id", type="integer", example=5),
|
||||
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||
* @OA\Property(property="schedule_id", type="integer", example=2),
|
||||
* @OA\Property(property="status", type="string", enum={"pending","confirmed","rejected","completed","cancelled"}, example="pending"),
|
||||
* @OA\Property(property="participants", type="integer", example=2),
|
||||
* @OA\Property(property="note", type="string", nullable=true, example=""),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||
* )
|
||||
*/
|
||||
class PublicApiDoc
|
||||
{
|
||||
/**
|
||||
* 查詢潛水課程列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/diving-offers",
|
||||
* summary="查詢潛水課程列表",
|
||||
* description="支援關鍵字、地區、標籤篩選,回傳分頁結果",
|
||||
* operationId="listDivingOffers",
|
||||
* tags={"公開課程"},
|
||||
* @OA\Parameter(name="q", in="query", description="關鍵字搜尋", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="region", in="query", description="地區篩選", @OA\Schema(type="string", example="墾丁")),
|
||||
* @OA\Parameter(name="tag", in="query", description="標籤篩選", @OA\Schema(type="string", example="OWD")),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50)", @OA\Schema(type="integer", default=15, maximum=50)),
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="查詢成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/DivingOffer")
|
||||
* ),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listDivingOffers()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一潛水課程
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/diving-offers/{id}",
|
||||
* summary="取得單一潛水課程",
|
||||
* description="回傳課程詳情,包含封面圖片與相簿",
|
||||
* operationId="getDivingOffer",
|
||||
* tags={"公開課程"},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* required=true,
|
||||
* description="課程 ID",
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=404,
|
||||
* description="課程不存在",
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function getDivingOffer()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得課程評價列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/diving-offers/{id}/reviews",
|
||||
* summary="取得課程評價列表",
|
||||
* description="回傳評分摘要、分頁評價列表與分頁 meta",
|
||||
* operationId="listOfferReviews",
|
||||
* tags={"公開課程"},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="sort", in="query", description="排序方式(newest / helpful)", @OA\Schema(type="string", enum={"newest","helpful"}, default="newest")),
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50)", @OA\Schema(type="integer", default=15, maximum=50)),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(
|
||||
* property="summary",
|
||||
* type="object",
|
||||
* @OA\Property(property="average_rating", type="number", format="float", example=4.2),
|
||||
* @OA\Property(property="total_reviews", type="integer", example=24),
|
||||
* @OA\Property(property="distribution", type="object",
|
||||
* @OA\Property(property="1", type="integer", example=1),
|
||||
* @OA\Property(property="2", type="integer", example=2),
|
||||
* @OA\Property(property="3", type="integer", example=3),
|
||||
* @OA\Property(property="4", type="integer", example=8),
|
||||
* @OA\Property(property="5", type="integer", example=10)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="reviews",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Review")
|
||||
* ),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=404,
|
||||
* description="課程不存在",
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listOfferReviews()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得課程時段列表
|
||||
*
|
||||
* @OA\Get(
|
||||
* path="/diving-offers/{id}/schedules",
|
||||
* summary="取得課程時段列表",
|
||||
* description="回傳指定課程的所有可用時段",
|
||||
* operationId="listOfferSchedules",
|
||||
* tags={"公開課程"},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/CourseSchedule")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=404,
|
||||
* description="課程不存在",
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listOfferSchedules()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
// 該模型已棄用,請使用ProviderProfile模型
|
||||
class CoachProfile extends Model
|
||||
{
|
||||
/**
|
||||
* 可以批量分配的屬性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'bio',
|
||||
'expertise',
|
||||
'certification',
|
||||
'experience',
|
||||
'rating',
|
||||
'availability'
|
||||
];
|
||||
|
||||
/**
|
||||
* 與用戶的關聯
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Plan extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('subscriptions');
|
||||
Schema::dropIfExists('plans');
|
||||
Schema::dropIfExists('coach_member');
|
||||
Schema::dropIfExists('coach_profiles');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 這些表已確認廢棄,不提供還原
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Models\AdminProfile;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\MemberProfile;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\Review;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
if (User::where('email', 'bluedive@cfdive.com')->exists()) {
|
||||
$this->command->info('Demo 資料已存在,略過。');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->createAdmin();
|
||||
$providers = $this->createProviders();
|
||||
$members = $this->createMembers();
|
||||
$offers = $this->createOffers($providers);
|
||||
|
||||
[$pastSchedules, $futureSchedules] = $this->createSchedules($offers);
|
||||
|
||||
$completedBookings = $this->createCompletedBookings($pastSchedules, $members, $offers);
|
||||
$this->createPendingBookings($futureSchedules, $members, $offers);
|
||||
$this->createReviews($completedBookings);
|
||||
$this->refreshOfferStats($offers);
|
||||
$this->createNotifications($completedBookings);
|
||||
|
||||
$this->command->info('✅ Demo 資料建立完成');
|
||||
$this->printSummary($providers, $members);
|
||||
}
|
||||
|
||||
private function createAdmin(): void
|
||||
{
|
||||
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
|
||||
'name' => '平台管理員', 'password' => Hash::make('password123'),
|
||||
'role' => 'admin', 'is_active' => true,
|
||||
]);
|
||||
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
|
||||
}
|
||||
|
||||
private function createProviders(): array
|
||||
{
|
||||
$providerData = [
|
||||
[
|
||||
'email' => 'bluedive@cfdive.com', 'name' => '林志遠',
|
||||
'business_name' => '墾丁藍海潛水',
|
||||
'description' => '屹立墾丁十五年,PADI 五星級授權中心,專業教學團隊由 8 名持照教練組成,累計培訓超過三千名學員。',
|
||||
'contact_phone' => '08-8881234', 'contact_email' => 'bluedive@cfdive.com',
|
||||
'contact_person' => '林志遠', 'address' => '屏東縣恆春鎮南灣路 88 號',
|
||||
'dive_sites' => '南灣,萬里桐,核三出水口,後壁湖',
|
||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
|
||||
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
|
||||
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
|
||||
'business_hours' => '每日 07:30–18:00', 'is_verified' => true,
|
||||
],
|
||||
[
|
||||
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
|
||||
'business_name' => '綠島深潛體驗',
|
||||
'description' => '深耕綠島潛水生態導覽,主打小班教學(每班最多 4 人),讓每位學員獲得充足的水下指導時間。',
|
||||
'contact_phone' => '089-671234', 'contact_email' => 'greendive@cfdive.com',
|
||||
'contact_person' => '陳美玲', 'address' => '台東縣綠島鄉南寮村 15 號',
|
||||
'dive_sites' => '大香菇,石朗,柴口,哈巴狗,雞仔礁',
|
||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
|
||||
'certifications' => 'PADI 潛水中心,SSI 認證教練',
|
||||
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
|
||||
'business_hours' => '每日 07:00–17:30', 'is_verified' => true,
|
||||
],
|
||||
[
|
||||
'email' => 'islet@cfdive.com', 'name' => '張大偉',
|
||||
'business_name' => '小琉球海洋探索',
|
||||
'description' => '小琉球在地老字號潛水店,最熟悉當地珊瑚礁地形與海龜族群,提供最道地的水下生態體驗。',
|
||||
'contact_phone' => '08-8613456', 'contact_email' => 'islet@cfdive.com',
|
||||
'contact_person' => '張大偉', 'address' => '屏東縣琉球鄉中山路 25 號',
|
||||
'dive_sites' => '花瓶石,美人洞,厚石裙礁,杉福生態廊道,烏鬼洞',
|
||||
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
|
||||
'certifications' => 'PADI 授權潛水中心',
|
||||
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
|
||||
'business_hours' => '每日 08:00–17:00', 'is_verified' => true,
|
||||
],
|
||||
[
|
||||
'email' => 'northdive@cfdive.com', 'name' => '王建國',
|
||||
'business_name' => '北海岸潛水俱樂部',
|
||||
'description' => '以龍洞、和平島為主場地,北部最大的技術潛水訓練基地,提供 Tec 課程與洞穴潛水入門訓練。',
|
||||
'contact_phone' => '02-24912345', 'contact_email' => 'northdive@cfdive.com',
|
||||
'contact_person' => '王建國', 'address' => '新北市貢寮區龍洞街 56 號',
|
||||
'dive_sites' => '龍洞灣,龍洞南口,鼻頭角,和平島,望海巷',
|
||||
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
|
||||
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
|
||||
'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修',
|
||||
'business_hours' => '週一公休,週二至週日 08:00–18:00', 'is_verified' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$providers = [];
|
||||
foreach ($providerData as $data) {
|
||||
$user = User::firstOrCreate(['email' => $data['email']], [
|
||||
'name' => $data['name'], 'password' => Hash::make('password123'),
|
||||
'role' => 'provider', 'is_active' => true,
|
||||
]);
|
||||
ProviderProfile::firstOrCreate(['user_id' => $user->id], [
|
||||
'business_name' => $data['business_name'],
|
||||
'description' => $data['description'],
|
||||
'contact_phone' => $data['contact_phone'],
|
||||
'contact_email' => $data['contact_email'],
|
||||
'contact_person' => $data['contact_person'],
|
||||
'address' => $data['address'],
|
||||
'dive_sites' => $data['dive_sites'],
|
||||
'services' => $data['services'],
|
||||
'certifications' => $data['certifications'],
|
||||
'facilities' => $data['facilities'],
|
||||
'business_hours' => $data['business_hours'],
|
||||
'is_verified' => $data['is_verified'],
|
||||
'rating' => 0,
|
||||
]);
|
||||
$providers[] = $user;
|
||||
}
|
||||
return $providers;
|
||||
}
|
||||
|
||||
private function createMembers(): array
|
||||
{
|
||||
$memberData = [
|
||||
['email' => 'alice@demo.com', 'name' => '李小雨', 'gender' => 'female', 'birthday' => '1995-03-12', 'address' => '台北市大安區仁愛路四段 100 號', 'emergency_contact' => '李大明', 'emergency_phone' => '0912001001'],
|
||||
['email' => 'bob@demo.com', 'name' => '王柏翰', 'gender' => 'male', 'birthday' => '1990-07-24', 'address' => '新北市板橋區文化路二段 55 號', 'emergency_contact' => '王美惠', 'emergency_phone' => '0912001002'],
|
||||
['email' => 'carol@demo.com', 'name' => '張雅婷', 'gender' => 'female', 'birthday' => '1998-11-05', 'address' => '台中市西屯區台灣大道三段 210 號', 'emergency_contact' => '張志明', 'emergency_phone' => '0912001003'],
|
||||
['email' => 'david@demo.com', 'name' => '陳俊宏', 'gender' => 'male', 'birthday' => '1988-02-18', 'address' => '高雄市苓雅區中正一路 88 號', 'emergency_contact' => '陳淑芬', 'emergency_phone' => '0912001004'],
|
||||
['email' => 'eva@demo.com', 'name' => '林芷萱', 'gender' => 'female', 'birthday' => '2000-06-30', 'address' => '台南市東區東門路一段 33 號', 'emergency_contact' => '林國雄', 'emergency_phone' => '0912001005'],
|
||||
['email' => 'frank@demo.com', 'name' => '黃建豪', 'gender' => 'male', 'birthday' => '1993-09-15', 'address' => '桃園市中壢區中央路 120 號', 'emergency_contact' => '黃淑娟', 'emergency_phone' => '0912001006'],
|
||||
['email' => 'grace@demo.com', 'name' => '吳怡君', 'gender' => 'female', 'birthday' => '1996-04-22', 'address' => '新竹市東區光復路二段 101 號', 'emergency_contact' => '吳明德', 'emergency_phone' => '0912001007'],
|
||||
['email' => 'henry@demo.com', 'name' => '劉家豪', 'gender' => 'male', 'birthday' => '1985-12-08', 'address' => '基隆市中正區中正路 200 號', 'emergency_contact' => '劉淑英', 'emergency_phone' => '0912001008'],
|
||||
];
|
||||
|
||||
$members = [];
|
||||
foreach ($memberData as $data) {
|
||||
$user = User::firstOrCreate(['email' => $data['email']], [
|
||||
'name' => $data['name'], 'password' => Hash::make('password123'),
|
||||
'role' => 'member', 'is_active' => true,
|
||||
]);
|
||||
MemberProfile::firstOrCreate(['user_id' => $user->id], [
|
||||
'birthday' => $data['birthday'],
|
||||
'gender' => $data['gender'],
|
||||
'address' => $data['address'],
|
||||
'emergency_contact' => $data['emergency_contact'],
|
||||
'emergency_phone' => $data['emergency_phone'],
|
||||
]);
|
||||
$members[] = $user;
|
||||
}
|
||||
return $members;
|
||||
}
|
||||
|
||||
private function createOffers(array $providers): array
|
||||
{
|
||||
$offersData = [
|
||||
// 墾丁藍海潛水
|
||||
['title' => '墾丁南灣體驗潛水', 'location' => '墾丁', 'spot' => '南灣', 'price' => 3800, 'region' => '南部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險'], 'description' => '適合零基礎的海水體驗課程,由持照教練一對一陪同入水,南灣能見度佳,適合拍攝水下照片。課程含教學、裝備與保險,歡迎親友組團報名。'],
|
||||
['title' => '墾丁 PADI OW 開放水域認證', 'location' => '墾丁', 'spot' => '核三出水口', 'price' => 14800, 'region' => '南部', 'tag' => '認證課程', 'badges' => ['PADI認證', '含裝備', '教材費含', '保險'], 'description' => '4 天完整 OW 課程,含學科、泳池練習與 4 支開放水域訓練潛水。核三出水口水溫穩定,能見度超過 15 米,是取得人生第一張 PADI 證書的最佳地點。'],
|
||||
['title' => '墾丁萬里桐夜潛探秘', 'location' => '墾丁', 'spot' => '萬里桐', 'price' => 5500, 'region' => '南部', 'tag' => '進階', 'badges' => ['AOW認證', '含裝備', '保險', '夜間燈光設備'], 'description' => '僅開放給持有 OW 以上證書的潛水員。夜晚的萬里桐章魚出沒、螢光珊瑚、夜行魚類——這是白天潛水感受不到的神秘體驗。'],
|
||||
// 綠島深潛體驗
|
||||
['title' => '綠島大香菇體驗潛水', 'location' => '綠島', 'spot' => '大香菇', 'price' => 4500, 'region' => '東部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險', '水下攝影服務'], 'description' => '大香菇是綠島最著名的潛點,獨特的菇形珊瑚礁聳立在 20 米深處,四周魚群聚集。小班教學讓每位學員都獲得充分指導,視覺震撼無與倫比。'],
|
||||
['title' => '綠島 PADI AOW 進階認證', 'location' => '綠島', 'spot' => '石朗', 'price' => 16800, 'region' => '東部', 'tag' => '認證課程', 'badges' => ['PADI認證', '含裝備', '教材費含', '保險'], 'description' => '5 支多元進階潛水(深潛、導航、水下攝影、夜潛、自然生態),全程在石朗保護區完成。石朗是台灣保育最完善的珊瑚礁區之一,生態豐富多樣。'],
|
||||
['title' => '綠島柴口海龜共游體驗', 'location' => '綠島', 'spot' => '柴口', 'price' => 6800, 'region' => '東部', 'tag' => '生態', 'badges' => ['含裝備', '生態解說', '保險', '小班4人'], 'description' => '柴口是台灣最容易近距離觀察綠蠵龜的潛點,我們與海龜共存超過 20 年,熟知牠們的作息。每梯次限 4 人,確保對環境最低干擾。'],
|
||||
// 小琉球海洋探索
|
||||
['title' => '小琉球花瓶石浮潛', 'location' => '小琉球', 'spot' => '花瓶石', 'price' => 1800, 'region' => '南部', 'tag' => '浮潛', 'badges' => ['含裝備', '教練帶領', '適合全家', '保險'], 'description' => '不需要任何潛水經驗,只要會游泳或穿著救生衣就能參加!花瓶石淺灘珊瑚礁完整,是小朋友和第一次接觸海洋的旅客最佳選擇。'],
|
||||
['title' => '小琉球美人洞海龜體驗潛水', 'location' => '小琉球', 'spot' => '美人洞', 'price' => 5200, 'region' => '南部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險', '海龜保證'], 'description' => '小琉球海龜密度全台第一,美人洞水下可同時觀察到 3–8 隻海龜悠游。體驗課程不需事先考照,教練確保每位學員安全觀察到海龜。'],
|
||||
['title' => '小琉球 PADI OW 認證課程', 'location' => '小琉球', 'spot' => '厚石裙礁', 'price' => 12800, 'region' => '南部', 'tag' => '認證課程', 'badges' => ['PADI認證', '含裝備', '教材費含', '保險', '海龜同行'], 'description' => '結合 PADI OW 課程與小琉球生態,4 天課程讓你在取得認證的同時深度認識台灣海龜生態。厚石裙礁水況穩定,是絕佳的訓練環境。'],
|
||||
// 北海岸潛水俱樂部
|
||||
['title' => '龍洞灣體驗潛水', 'location' => '龍洞', 'spot' => '龍洞灣', 'price' => 3200, 'region' => '北部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險'], 'description' => '龍洞是北台灣能見度最穩定的潛點,清澈海水與豐富的溫帶魚種。北部居民不用遠赴南部,週末就能享受高品質的潛水體驗。'],
|
||||
['title' => '東北角鼻頭角生態導覽潛水', 'location' => '東北角', 'spot' => '鼻頭角', 'price' => 4800, 'region' => '北部', 'tag' => '生態', 'badges' => ['含裝備', '生態解說', '保險', 'OW以上'], 'description' => '東北角海域受黑潮支流影響,生物多樣性極高,鼻頭角是章魚、河豚、獅子魚的熱區。需持 OW 以上證書,適合進階潛水員。'],
|
||||
['title' => '龍洞技術潛水入門', 'location' => '龍洞', 'spot' => '龍洞南口', 'price' => 9800, 'region' => '北部', 'tag' => '進階', 'badges' => ['AOW認證', '含裝備', '保險', 'Tec入門', '小班2人'], 'description' => '學習雙瓶氣瓶配置、Backplate 裝具與減壓程序基礎。龍洞南口地形複雜,是訓練水下導航與定位的絕佳環境,每班限 2 人。'],
|
||||
];
|
||||
|
||||
$offers = [];
|
||||
foreach ($offersData as $index => $data) {
|
||||
$providerIndex = (int) floor($index / 3);
|
||||
$offer = DivingOffer::firstOrCreate(
|
||||
['title' => $data['title'], 'provider_id' => $providers[$providerIndex]->id],
|
||||
array_merge($data, [
|
||||
'provider_id' => $providers[$providerIndex]->id,
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
])
|
||||
);
|
||||
$offers[] = $offer;
|
||||
}
|
||||
return $offers;
|
||||
}
|
||||
|
||||
private function createSchedules(array $offers): array
|
||||
{
|
||||
$pastSchedules = [];
|
||||
$futureSchedules = [];
|
||||
|
||||
foreach ($offers as $index => $offer) {
|
||||
$pastDate = now()->subDays(90 + $index * 5)->toDateString();
|
||||
$futureDate = now()->addDays(30 + $index * 7)->toDateString();
|
||||
|
||||
$pastSchedules[] = CourseSchedule::firstOrCreate(
|
||||
['diving_offer_id' => $offer->id, 'scheduled_date' => $pastDate],
|
||||
[
|
||||
'provider_id' => $offer->provider_id,
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 6,
|
||||
'current_participants' => 4,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]
|
||||
);
|
||||
|
||||
$futureSchedules[] = CourseSchedule::firstOrCreate(
|
||||
['diving_offer_id' => $offer->id, 'scheduled_date' => $futureDate],
|
||||
[
|
||||
'provider_id' => $offer->provider_id,
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 6,
|
||||
'current_participants' => 1,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return [$pastSchedules, $futureSchedules];
|
||||
}
|
||||
|
||||
private function createCompletedBookings(array $pastSchedules, array $members, array $offers): array
|
||||
{
|
||||
$completedBookings = [];
|
||||
foreach ($pastSchedules as $index => $schedule) {
|
||||
$member = $members[$index % count($members)];
|
||||
$offer = $offers[$index];
|
||||
$booking = Booking::firstOrCreate(
|
||||
['schedule_id' => $schedule->id, 'member_id' => $member->id],
|
||||
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
|
||||
);
|
||||
$completedBookings[] = [
|
||||
'booking' => $booking,
|
||||
'member' => $member,
|
||||
'offer' => $offer,
|
||||
'schedule' => $schedule,
|
||||
];
|
||||
}
|
||||
return $completedBookings;
|
||||
}
|
||||
|
||||
private function createPendingBookings(array $futureSchedules, array $members, array $offers): void
|
||||
{
|
||||
$statusCycle = [
|
||||
BookingStatus::Pending, BookingStatus::Confirmed,
|
||||
BookingStatus::Pending, BookingStatus::Confirmed,
|
||||
BookingStatus::Pending, BookingStatus::Confirmed,
|
||||
];
|
||||
|
||||
foreach ($futureSchedules as $index => $schedule) {
|
||||
$member = $members[($index + 4) % count($members)];
|
||||
$offer = $offers[$index];
|
||||
Booking::firstOrCreate(
|
||||
['schedule_id' => $schedule->id, 'member_id' => $member->id],
|
||||
[
|
||||
'participants' => 1,
|
||||
'total_price' => $offer->price,
|
||||
'status' => $statusCycle[$index % count($statusCycle)],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function createReviews(array $completedBookings): void
|
||||
{
|
||||
$reviewTexts = [
|
||||
5 => [
|
||||
'教練超級專業!水下說明非常清楚,全程讓我感到安心。第一次潛水就看到這麼多魚,感動到想哭,絕對會再來!',
|
||||
'課程安排超完善,從學科到實際下水都循序漸進。教練英文也很好,外國朋友一起來完全沒問題。強力推薦!',
|
||||
'海龜真的出現了!三隻海龜就在我旁邊游過,那一刻我整個人都愣住了,這輩子難忘的體驗。',
|
||||
'認證課程物超所值,教練非常有耐心,每個步驟都解釋得很清楚,讓我順利考到 OW 證書!',
|
||||
'夜潛太刺激了!螢光珊瑚、章魚、獅子魚,白天完全看不到的生物都出來了,裝備品質很好,非常安全。',
|
||||
],
|
||||
4 => [
|
||||
'整體體驗很棒,珊瑚保育做得很完善,能見度超過 20 米。等待入水時間稍長,希望未來能改善行程安排。',
|
||||
'教練很專業,課程內容扎實。裝備保養良好,建議加入更多生態解說,讓學員更了解看到的生物。',
|
||||
'潛點選擇很棒,教練熟悉地形。課程說明文件清楚,行前準備資訊充足,讓第一次潛水的我很放心。',
|
||||
],
|
||||
3 => [
|
||||
'潛水本身體驗還不錯,但課程安排感覺有點趕,希望每個環節能多一點時間。教練很專業,人手稍嫌不足。',
|
||||
'裝備稍舊但堪用,整體體驗中規中矩。適合想嘗試的初學者,進階潛水員可能會覺得行程太簡單。',
|
||||
],
|
||||
];
|
||||
|
||||
$skipIndexes = [3, 9];
|
||||
|
||||
foreach ($completedBookings as $index => $data) {
|
||||
if (in_array($index, $skipIndexes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rating = match (true) {
|
||||
$index < 5 => 5,
|
||||
$index < 8 => 4,
|
||||
default => 3,
|
||||
};
|
||||
|
||||
$textPool = $reviewTexts[$rating];
|
||||
Review::firstOrCreate(
|
||||
['diving_offer_id' => $data['offer']->id, 'member_id' => $data['member']->id],
|
||||
[
|
||||
'rating' => $rating,
|
||||
'comment' => $textPool[$index % count($textPool)],
|
||||
'helpful_count' => rand(0, 20),
|
||||
'is_edited' => false,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function refreshOfferStats(array $offers): void
|
||||
{
|
||||
foreach ($offers as $offer) {
|
||||
$reviews = Review::where('diving_offer_id', $offer->id)->get();
|
||||
if ($reviews->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
$offer->update([
|
||||
'rating' => round($reviews->avg('rating'), 1),
|
||||
'reviews' => $reviews->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createNotifications(array $completedBookings): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach (array_slice($completedBookings, 0, 6) as $data) {
|
||||
['booking' => $booking, 'offer' => $offer, 'member' => $member, 'schedule' => $schedule] = $data;
|
||||
$date = $schedule->scheduled_date->toDateString();
|
||||
$baseUrl = config('app.frontend_url');
|
||||
|
||||
$rows[] = [
|
||||
'id' => Str::uuid()->toString(),
|
||||
'type' => 'App\Notifications\BookingConfirmedNotification',
|
||||
'notifiable_type' => 'App\Models\User',
|
||||
'notifiable_id' => $member->id,
|
||||
'data' => json_encode([
|
||||
'type' => 'booking_confirmed',
|
||||
'title' => '預約已確認',
|
||||
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date})",
|
||||
'action_url' => "{$baseUrl}/my-bookings",
|
||||
'related_id' => $booking->id,
|
||||
'related_type' => 'booking',
|
||||
]),
|
||||
'read_at' => now()->subDays(rand(20, 60))->toDateTimeString(),
|
||||
'created_at' => now()->subDays(rand(61, 90))->toDateTimeString(),
|
||||
'updated_at' => now()->subDays(rand(20, 60))->toDateTimeString(),
|
||||
];
|
||||
|
||||
$rows[] = [
|
||||
'id' => Str::uuid()->toString(),
|
||||
'type' => 'App\Notifications\BookingCompletedNotification',
|
||||
'notifiable_type' => 'App\Models\User',
|
||||
'notifiable_id' => $member->id,
|
||||
'data' => json_encode([
|
||||
'type' => 'booking_completed',
|
||||
'title' => '課程已完成,歡迎留下評價',
|
||||
'body' => "你的《{$offer->title}》課程已完成,分享你的潛水心得讓更多人看到!",
|
||||
'action_url' => "{$baseUrl}/my-bookings",
|
||||
'related_id' => $booking->id,
|
||||
'related_type' => 'booking',
|
||||
]),
|
||||
'read_at' => null,
|
||||
'created_at' => now()->subDays(rand(5, 19))->toDateTimeString(),
|
||||
'updated_at' => now()->subDays(rand(5, 19))->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
DB::table('notifications')->insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
private function printSummary(array $providers, array $members): void
|
||||
{
|
||||
$this->command->info('');
|
||||
$this->command->info(' Admin: admin@cfdive.com / password123');
|
||||
$this->command->info('');
|
||||
$this->command->info(' 教練帳號(密碼均為 password123):');
|
||||
foreach ($providers as $provider) {
|
||||
$profile = ProviderProfile::where('user_id', $provider->id)->value('business_name');
|
||||
$this->command->info(" {$provider->email} ─ {$profile}");
|
||||
}
|
||||
$this->command->info('');
|
||||
$this->command->info(' 會員帳號(密碼均為 password123):');
|
||||
foreach ($members as $member) {
|
||||
$this->command->info(" {$member->email} ({$member->name})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-24
|
||||
@@ -0,0 +1,63 @@
|
||||
## Context
|
||||
|
||||
現有 `app/Docs/AuthApiDoc.php` 已建立 Swagger 基礎設定(`@OA\Info`、`@OA\Server`、`@OA\SecurityScheme`)並文件化 15 個 Auth 端點。其餘 61 個端點分散在 8 個 Controller 中,沒有任何 `@OA` 標注。`darkaonline/l5-swagger` 已安裝,執行 `php artisan l5-swagger:generate` 即可重新產生 JSON。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 補齊所有 61 個未文件化端點的 `@OA` 標注
|
||||
- 定義共用 Schema(分頁 meta、統一錯誤格式、DivingOffer、Review、Booking 等)
|
||||
- 文件依模組分檔,每個新檔對應一個 `app/Docs/*Doc.php`
|
||||
- 每個端點包含:summary、parameters、request body(POST/PUT)、response(200/201/400/401/403/422)
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改任何 Controller / Route / Model 邏輯
|
||||
- 不為尚未實作的端點(金流、訂閱)補文件
|
||||
|
||||
## Decisions
|
||||
|
||||
### 決策 1:分檔策略 — 依模組建立獨立 Doc 類別
|
||||
|
||||
**選擇**:每個模組一個 `app/Docs/*Doc.php`
|
||||
|
||||
| 檔案 | 涵蓋端點 |
|
||||
|------|---------|
|
||||
| `AuthApiDoc.php` | **修正路徑錯誤**(`/register/member` → `/member/register` 等);補 `POST /logout`、`GET /user` |
|
||||
| `PublicApiDoc.php` | 公開端點(diving-offers、reviews、schedules) |
|
||||
| `MemberApiDoc.php` | Member bookings、reviews、helpful、notifications |
|
||||
| `ProviderApiDoc.php` | Provider offers、images、schedules、bookings |
|
||||
| `AdminApiDoc.php` | Admin stats、users、offers、bookings、reviews |
|
||||
| `AuthSupplementDoc.php` | **僅** Google OAuth 2 個端點(`SocialAuthController`) |
|
||||
|
||||
**理由**:單一大檔難以維護;按模組分檔讓每個 Doc 類別職責清晰,對應 Controller 結構
|
||||
|
||||
**替代方案**:直接在 Controller 方法上加 `@OA` → 拒絕,Controller 文件與業務邏輯混雜,讀性差
|
||||
|
||||
### 決策 2:共用 Schema 集中定義在 `PublicApiDoc.php`
|
||||
|
||||
共用 Schema(`DivingOfferSchema`、`ReviewSchema`、`BookingSchema`、`PaginationMeta`、`ApiErrorResponse`)集中放在 `PublicApiDoc.php` 頂層 `@OA\Schema` 標注。
|
||||
|
||||
**理由**:l5-swagger 掃描所有 `app/` 目錄,Schema 定義位置不影響其他檔案引用;放在 Public 層語意上最自然(核心資料結構)
|
||||
|
||||
### 決策 3:Security 標注策略
|
||||
|
||||
- 公開端點:不加 `security`
|
||||
- Member 端點:`security={{"bearerAuth": {}}}`(現有 scheme)
|
||||
- Provider 端點:`security={{"bearerAuth": {}}}`
|
||||
- Admin 端點:`security={{"bearerAuth": {}}}`
|
||||
|
||||
三個 role 共用同一個 `bearerAuth` scheme,由後端 middleware 區分角色,不需要在 Swagger 定義三個不同 scheme。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk] 手寫標注與實際 response 不同步** → 以實際 Controller 程式碼為準手寫;未來有 API 變更時需同步更新 Doc 檔
|
||||
- **[Risk] Schema 巢狀複雜導致 Swagger UI 渲染慢** → 分頁 response 使用 `allOf` 組合,避免深層巢狀
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 依序建立 5 個新 Doc 檔案
|
||||
2. 在容器內執行 `php artisan l5-swagger:generate`
|
||||
3. 開啟 `http://localhost:8080/api/documentation` 確認 UI 正確渲染
|
||||
4. 確認所有 tag 與端點均出現在 Swagger UI
|
||||
|
||||
**Rollback**:直接刪除新增的 Doc 檔案,重新 generate 即可回復到只有 Auth 端點的狀態
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
目前 Swagger 文件僅涵蓋 15 個 Auth 端點,其餘 61 個端點(課程、評價、預約、通知、圖片上傳、Coach Portal、Admin Panel)完全未文件化,導致前後端協作與第三方整合缺乏規格依據。現有 `darkaonline/l5-swagger` 套件已安裝,補全文件的邊際成本低。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 為所有未文件化端點補上 `@OA` PHPDoc 標注(`app/Docs/` 目錄下依模組分檔)
|
||||
- 定義完整的 Request / Response Schema(含分頁 meta、錯誤格式)
|
||||
- 補全 Swagger Security 設定(Bearer token per-role)
|
||||
- 重新產生 `storage/api-docs/api-docs.json`
|
||||
|
||||
**端點範圍(新增 61 個):**
|
||||
- Public API:GET diving-offers 列表/詳情、reviews、schedules(4)
|
||||
- Member API:bookings CRUD、reviews CRUD、helpful 投票、notifications(10)
|
||||
- Provider API:offers CRUD + 圖片、schedules CRUD、bookings 管理(19)
|
||||
- Admin API:stats、members、providers、offers、bookings、reviews 管理(17)
|
||||
- Auth 補全:Google OAuth、change-password(3 roles)(7)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `swagger-public-api`:公開端點 Swagger 文件(diving-offers、reviews、schedules)
|
||||
- `swagger-member-api`:Member 端點 Swagger 文件(bookings、reviews、notifications)
|
||||
- `swagger-provider-api`:Provider 端點 Swagger 文件(offers、images、schedules、bookings)
|
||||
- `swagger-admin-api`:Admin 端點 Swagger 文件(stats、user/offer/booking/review 管理)
|
||||
- `swagger-auth-supplement`:補全 Auth 端點(Google OAuth、change-password)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(無,本 change 純為文件補全,不變更任何 API 行為)
|
||||
|
||||
## Impact
|
||||
|
||||
- 新增:`app/Docs/PublicApiDoc.php`、`MemberApiDoc.php`、`ProviderApiDoc.php`、`AdminApiDoc.php`、`AuthSupplementDoc.php`
|
||||
- 更新:`storage/api-docs/api-docs.json`(自動產生)
|
||||
- 不影響任何 Controller、Model、Route、Migration
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin 端點 Swagger 文件
|
||||
|
||||
`app/Docs/AdminApiDoc.php` SHALL 文件化所有需要 Admin Bearer token 的管理端點。
|
||||
|
||||
#### Scenario: Admin stats 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** `GET /admin/stats` 端點有文件,response 包含 `total_members`、`total_providers`、`total_offers`;403 非 admin 亦有定義
|
||||
|
||||
#### Scenario: Admin 會員管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,標示 `security: bearerAuth`:
|
||||
- `GET /admin/members`(分頁 response,含 member_profile)
|
||||
- `GET /admin/members/{id}`
|
||||
- `PUT /admin/members/{id}/toggle-active`(response: is_active 新狀態)
|
||||
- `GET /admin/check-member/{id}`
|
||||
|
||||
#### Scenario: Admin 教練管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /admin/providers`(分頁 response,含 provider_profile)
|
||||
- `GET /admin/providers/{id}`
|
||||
- `PUT /admin/providers/{id}/toggle-active`
|
||||
- `PUT /admin/providers/{id}/toggle-verified`(response: is_verified 新狀態)
|
||||
- `GET /admin/check-provider/{id}`
|
||||
|
||||
#### Scenario: Admin 課程、預約、評價管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /admin/offers`(分頁 response)
|
||||
- `DELETE /admin/offers/{id}`
|
||||
- `GET /admin/bookings`(分頁 response)
|
||||
- `PUT /admin/bookings/{id}/complete`
|
||||
- `GET /admin/reviews`(分頁 response,含 per_page 參數,最大 100)
|
||||
- `DELETE /admin/reviews/{id}`
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Auth 補全端點 Swagger 文件
|
||||
|
||||
`app/Docs/AuthSupplementDoc.php` SHALL 補全未文件化的 Auth 相關端點(Google OAuth、change-password)。
|
||||
|
||||
#### Scenario: Google OAuth 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /auth/google/redirect`(response: redirect_url,說明用途為取得 Google OAuth redirect URL)
|
||||
- `GET /auth/google/callback`(response: token + user,說明此為 OAuth callback,通常由瀏覽器自動呼叫)
|
||||
|
||||
#### Scenario: change-password 端點文件化(三個 role)
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,各自標示對應 role 的 `security: bearerAuth`:
|
||||
- `PUT /member/change-password`(request: current_password、password、password_confirmation)
|
||||
- `PUT /provider/change-password`(同上)
|
||||
- `PUT /admin/change-password`(同上)
|
||||
- 422 驗證失敗 response 亦有定義
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Member 端點 Swagger 文件
|
||||
|
||||
`app/Docs/MemberApiDoc.php` SHALL 文件化所有需要 Member Bearer token 的端點(bookings、reviews、helpful 投票、notifications)。
|
||||
|
||||
#### Scenario: Member bookings 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,並標示 `security: bearerAuth`:
|
||||
- `POST /member/bookings`(request: schedule_id;response 201: Booking)
|
||||
- `GET /member/bookings`(response: Booking 陣列 + 分頁 meta)
|
||||
- `GET /member/bookings/{id}`(response: Booking 詳情)
|
||||
- `DELETE /member/bookings/{id}`(response 200: message)
|
||||
|
||||
#### Scenario: Member reviews 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `POST /member/reviews`(request: diving_offer_id、rating、comment;403 資格驗證失敗;422 重複評價)
|
||||
- `PUT /member/reviews/{id}`(request: rating?、comment?;403 非本人)
|
||||
- `DELETE /member/reviews/{id}`(403 非本人)
|
||||
- `POST /reviews/{id}/helpful`(toggle,response: helpful_count、has_voted)
|
||||
|
||||
#### Scenario: Member notifications 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /notifications`(response: 通知陣列 + 分頁 meta)
|
||||
- `GET /notifications/unread-count`(response: count)
|
||||
- `PATCH /notifications/{id}/read`
|
||||
- `PATCH /notifications/read-all`
|
||||
- `DELETE /notifications/{id}`
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Provider 端點 Swagger 文件
|
||||
|
||||
`app/Docs/ProviderApiDoc.php` SHALL 文件化所有需要 Provider Bearer token 的端點(offers CRUD、圖片、schedules、bookings)。
|
||||
|
||||
#### Scenario: Provider offers CRUD 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,標示 `security: bearerAuth`:
|
||||
- `GET /provider/offers`(分頁 response)
|
||||
- `POST /provider/offers`(request: title、location、spot?、price、region、tag?、badges?、description?)
|
||||
- `GET /provider/offers/{id}`(403 非本人)
|
||||
- `PUT /provider/offers/{id}`(request 同上,所有欄位 nullable)
|
||||
- `DELETE /provider/offers/{id}`
|
||||
|
||||
#### Scenario: Provider 圖片管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `POST /provider/offers/{id}/cover`(multipart/form-data: image;response: cover_image_url)
|
||||
- `DELETE /provider/offers/{id}/cover`
|
||||
- `POST /provider/offers/{id}/images`(multipart/form-data: images[];最多 10 張)
|
||||
- `DELETE /provider/images/{id}`
|
||||
|
||||
#### Scenario: Provider schedules 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /provider/schedules`(query: offer_id?;response: CourseSchedule 陣列)
|
||||
- `POST /provider/schedules`(request: diving_offer_id、date、period、capacity)
|
||||
- `PUT /provider/schedules/{id}`
|
||||
- `DELETE /provider/schedules/{id}`
|
||||
|
||||
#### Scenario: Provider bookings 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /provider/bookings`(response: Booking 陣列,含 member 資訊)
|
||||
- `PUT /provider/bookings/{id}/confirm`
|
||||
- `PUT /provider/bookings/{id}/reject`
|
||||
- `PUT /provider/bookings/{id}/complete`
|
||||
- `PUT /provider/bookings/{id}/cancel`
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 公開端點 Swagger 文件及共用 Schema
|
||||
|
||||
`app/Docs/PublicApiDoc.php` SHALL 文件化所有無需認證的公開端點,並定義全域共用 Schema。
|
||||
|
||||
#### Scenario: 共用 Schema 定義完整
|
||||
|
||||
- **WHEN** 執行 `php artisan l5-swagger:generate`
|
||||
- **THEN** 以下 Schema 出現在產生的 JSON:`DivingOffer`、`Review`、`CourseSchedule`、`PaginationMeta`、`ApiErrorResponse`
|
||||
|
||||
#### Scenario: GET /api/diving-offers 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** `GET /diving-offers` 端點顯示 query parameters(`q`、`region`、`tag`、`per_page`、`page`)及包含分頁 meta 的 response schema
|
||||
|
||||
#### Scenario: GET /api/diving-offers/{id} 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** `GET /diving-offers/{id}` 端點顯示 path parameter `id` 及包含 `cover_image_url`、`images` 陣列的 response schema;404 response 亦有定義
|
||||
|
||||
#### Scenario: GET /api/diving-offers/{id}/reviews 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 端點顯示 `sort`(helpful/rating/newest)、`page`、`per_page` 參數;response 包含 `summary`(average、total、distribution)與分頁 `meta`
|
||||
|
||||
#### Scenario: GET /api/diving-offers/{id}/schedules 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 端點顯示 response 包含 `CourseSchedule` 陣列(id、date、period、capacity、booked_count、status)
|
||||
@@ -0,0 +1,44 @@
|
||||
## 1. 修正 AuthApiDoc.php 既有錯誤
|
||||
|
||||
- [x] 1.1 [後端] `AuthApiDoc.php` 修正所有路徑錯位:`/register/member` → `/member/register`、`/login/member` → `/member/login`、`/logout/member` → `/member/logout`、`/profile/member` → `/member/profile`(provider、admin 同樣修正)
|
||||
- [x] 1.2 [後端] `AuthApiDoc.php` 修正 change-password 路徑:`/password/member` → `/member/change-password`、`/password/provider` → `/provider/change-password`、`/password/admin` → `/admin/change-password`
|
||||
- [x] 1.3 [後端] `AuthApiDoc.php` 補上 `POST /logout`(通用登出)與 `GET /user`(取得當前使用者)
|
||||
|
||||
## 2. AuthSupplementDoc(Google OAuth 專用)
|
||||
|
||||
- [x] 2.1 [後端] 建立 `app/Docs/AuthSupplementDoc.php`,補上 `GET /auth/google/redirect`(response: redirect_url)與 `GET /auth/google/callback`(response: token + user)兩個端點
|
||||
|
||||
## 3. 共用 Schema 與 PublicApiDoc
|
||||
|
||||
- [x] 3.1 [後端] 建立 `app/Docs/PublicApiDoc.php`,定義共用 Schema:`DivingOffer`、`Review`、`CourseSchedule`、`Booking`、`PaginationMeta`、`ApiErrorResponse`
|
||||
- [x] 3.2 [後端] `PublicApiDoc.php` 補上 `GET /diving-offers`(query: q、region、tag、per_page、page;response: DivingOffer 分頁)
|
||||
- [x] 3.3 [後端] `PublicApiDoc.php` 補上 `GET /diving-offers/{id}`(response: DivingOffer 含 cover_image_url、images 陣列;404)
|
||||
- [x] 3.4 [後端] `PublicApiDoc.php` 補上 `GET /diving-offers/{id}/reviews`(query: sort、page、per_page;response: summary + reviews 分頁 + meta)
|
||||
- [x] 3.5 [後端] `PublicApiDoc.php` 補上 `GET /diving-offers/{id}/schedules`(response: CourseSchedule 陣列)
|
||||
|
||||
## 4. MemberApiDoc
|
||||
|
||||
- [x] 4.1 [後端] 建立 `app/Docs/MemberApiDoc.php`,補上 Member bookings:`POST /member/bookings`(201)、`GET /member/bookings`(分頁)、`GET /member/bookings/{id}`、`DELETE /member/bookings/{id}`
|
||||
- [x] 4.2 [後端] `MemberApiDoc.php` 補上 Member reviews:`POST /member/reviews`(403/422)、`PUT /member/reviews/{id}`(403)、`DELETE /member/reviews/{id}`(403)
|
||||
- [x] 4.3 [後端] `MemberApiDoc.php` 補上 `POST /reviews/{id}/helpful`(response: helpful_count、has_voted)
|
||||
- [x] 4.4 [後端] `MemberApiDoc.php` 補上 notifications:`GET /notifications`(分頁)、`GET /notifications/unread-count`、`PATCH /notifications/{id}/read`、`PATCH /notifications/read-all`、`DELETE /notifications/{id}`
|
||||
|
||||
## 5. ProviderApiDoc
|
||||
|
||||
- [x] 5.1 [後端] 建立 `app/Docs/ProviderApiDoc.php`,補上 Provider offers CRUD:`GET /provider/offers`、`POST /provider/offers`、`GET /provider/offers/{id}`(403)、`PUT /provider/offers/{id}`、`DELETE /provider/offers/{id}`
|
||||
- [x] 5.2 [後端] `ProviderApiDoc.php` 補上圖片管理:`POST /provider/offers/{id}/cover`(multipart)、`DELETE /provider/offers/{id}/cover`、`POST /provider/offers/{id}/images`、`DELETE /provider/images/{id}`
|
||||
- [x] 5.3 [後端] `ProviderApiDoc.php` 補上 schedules:`GET /provider/schedules`(query: offer_id?)、`POST /provider/schedules`、`PUT /provider/schedules/{id}`、`DELETE /provider/schedules/{id}`
|
||||
- [x] 5.4 [後端] `ProviderApiDoc.php` 補上 bookings 管理:`GET /provider/bookings`、`PUT /provider/bookings/{id}/confirm`、`PUT /provider/bookings/{id}/reject`、`PUT /provider/bookings/{id}/complete`、`PUT /provider/bookings/{id}/cancel`
|
||||
|
||||
## 6. AdminApiDoc
|
||||
|
||||
- [x] 6.1 [後端] 建立 `app/Docs/AdminApiDoc.php`,補上 `GET /admin/stats`(response: total_members/providers/offers;403)
|
||||
- [x] 6.2 [後端] `AdminApiDoc.php` 補上會員管理:`GET /admin/members`(分頁)、`GET /admin/members/{id}`、`PUT /admin/members/{id}/toggle-active`、`GET /admin/check-member/{id}`
|
||||
- [x] 6.3 [後端] `AdminApiDoc.php` 補上教練管理:`GET /admin/providers`(分頁)、`GET /admin/providers/{id}`、`PUT /admin/providers/{id}/toggle-active`、`PUT /admin/providers/{id}/toggle-verified`、`GET /admin/check-provider/{id}`
|
||||
- [x] 6.4 [後端] `AdminApiDoc.php` 補上課程/預約/評價管理:`GET /admin/offers`、`DELETE /admin/offers/{id}`、`GET /admin/bookings`、`PUT /admin/bookings/{id}/complete`、`GET /admin/reviews`(per_page 最大 100)、`DELETE /admin/reviews/{id}`
|
||||
|
||||
## 7. 驗證
|
||||
|
||||
- [x] 7.1 [整合測試] 執行 `docker exec cfdive-app php artisan l5-swagger:generate`,確認無 parse error
|
||||
- [x] 7.2 [整合測試] 開啟 `http://localhost:8080/api/documentation`,展開各 tag 確認路徑正確(`/member/register` 而非 `/register/member`)
|
||||
- [x] 7.3 [整合測試] 確認端點總數 73(原始估計 76 因 AuthApiDoc 實際有 18 個端點非 15 個),`GET /diving-offers/{id}/reviews` 顯示分頁 meta schema
|
||||
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin 端點 Swagger 文件
|
||||
|
||||
`app/Docs/AdminApiDoc.php` SHALL 文件化所有需要 Admin Bearer token 的管理端點。
|
||||
|
||||
#### Scenario: Admin stats 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** `GET /admin/stats` 端點有文件,response 包含 `total_members`、`total_providers`、`total_offers`;403 非 admin 亦有定義
|
||||
|
||||
#### Scenario: Admin 會員管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,標示 `security: bearerAuth`:
|
||||
- `GET /admin/members`(分頁 response,含 member_profile)
|
||||
- `GET /admin/members/{id}`
|
||||
- `PUT /admin/members/{id}/toggle-active`(response: is_active 新狀態)
|
||||
- `GET /admin/check-member/{id}`
|
||||
|
||||
#### Scenario: Admin 教練管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /admin/providers`(分頁 response,含 provider_profile)
|
||||
- `GET /admin/providers/{id}`
|
||||
- `PUT /admin/providers/{id}/toggle-active`
|
||||
- `PUT /admin/providers/{id}/toggle-verified`(response: is_verified 新狀態)
|
||||
- `GET /admin/check-provider/{id}`
|
||||
|
||||
#### Scenario: Admin 課程、預約、評價管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /admin/offers`(分頁 response)
|
||||
- `DELETE /admin/offers/{id}`
|
||||
- `GET /admin/bookings`(分頁 response)
|
||||
- `PUT /admin/bookings/{id}/complete`
|
||||
- `GET /admin/reviews`(分頁 response,含 per_page 參數,最大 100)
|
||||
- `DELETE /admin/reviews/{id}`
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Auth 補全端點 Swagger 文件
|
||||
|
||||
`app/Docs/AuthSupplementDoc.php` SHALL 補全未文件化的 Auth 相關端點(Google OAuth、change-password)。
|
||||
|
||||
#### Scenario: Google OAuth 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /auth/google/redirect`(response: redirect_url,說明用途為取得 Google OAuth redirect URL)
|
||||
- `GET /auth/google/callback`(response: token + user,說明此為 OAuth callback,通常由瀏覽器自動呼叫)
|
||||
|
||||
#### Scenario: change-password 端點文件化(三個 role)
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,各自標示對應 role 的 `security: bearerAuth`:
|
||||
- `PUT /member/change-password`(request: current_password、password、password_confirmation)
|
||||
- `PUT /provider/change-password`(同上)
|
||||
- `PUT /admin/change-password`(同上)
|
||||
- 422 驗證失敗 response 亦有定義
|
||||
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Member 端點 Swagger 文件
|
||||
|
||||
`app/Docs/MemberApiDoc.php` SHALL 文件化所有需要 Member Bearer token 的端點(bookings、reviews、helpful 投票、notifications)。
|
||||
|
||||
#### Scenario: Member bookings 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,並標示 `security: bearerAuth`:
|
||||
- `POST /member/bookings`(request: schedule_id;response 201: Booking)
|
||||
- `GET /member/bookings`(response: Booking 陣列 + 分頁 meta)
|
||||
- `GET /member/bookings/{id}`(response: Booking 詳情)
|
||||
- `DELETE /member/bookings/{id}`(response 200: message)
|
||||
|
||||
#### Scenario: Member reviews 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `POST /member/reviews`(request: diving_offer_id、rating、comment;403 資格驗證失敗;422 重複評價)
|
||||
- `PUT /member/reviews/{id}`(request: rating?、comment?;403 非本人)
|
||||
- `DELETE /member/reviews/{id}`(403 非本人)
|
||||
- `POST /reviews/{id}/helpful`(toggle,response: helpful_count、has_voted)
|
||||
|
||||
#### Scenario: Member notifications 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /notifications`(response: 通知陣列 + 分頁 meta)
|
||||
- `GET /notifications/unread-count`(response: count)
|
||||
- `PATCH /notifications/{id}/read`
|
||||
- `PATCH /notifications/read-all`
|
||||
- `DELETE /notifications/{id}`
|
||||
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Provider 端點 Swagger 文件
|
||||
|
||||
`app/Docs/ProviderApiDoc.php` SHALL 文件化所有需要 Provider Bearer token 的端點(offers CRUD、圖片、schedules、bookings)。
|
||||
|
||||
#### Scenario: Provider offers CRUD 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件,標示 `security: bearerAuth`:
|
||||
- `GET /provider/offers`(分頁 response)
|
||||
- `POST /provider/offers`(request: title、location、spot?、price、region、tag?、badges?、description?)
|
||||
- `GET /provider/offers/{id}`(403 非本人)
|
||||
- `PUT /provider/offers/{id}`(request 同上,所有欄位 nullable)
|
||||
- `DELETE /provider/offers/{id}`
|
||||
|
||||
#### Scenario: Provider 圖片管理端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `POST /provider/offers/{id}/cover`(multipart/form-data: image;response: cover_image_url)
|
||||
- `DELETE /provider/offers/{id}/cover`
|
||||
- `POST /provider/offers/{id}/images`(multipart/form-data: images[];最多 10 張)
|
||||
- `DELETE /provider/images/{id}`
|
||||
|
||||
#### Scenario: Provider schedules 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /provider/schedules`(query: offer_id?;response: CourseSchedule 陣列)
|
||||
- `POST /provider/schedules`(request: diving_offer_id、date、period、capacity)
|
||||
- `PUT /provider/schedules/{id}`
|
||||
- `DELETE /provider/schedules/{id}`
|
||||
|
||||
#### Scenario: Provider bookings 端點文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 以下端點均有文件:
|
||||
- `GET /provider/bookings`(response: Booking 陣列,含 member 資訊)
|
||||
- `PUT /provider/bookings/{id}/confirm`
|
||||
- `PUT /provider/bookings/{id}/reject`
|
||||
- `PUT /provider/bookings/{id}/complete`
|
||||
- `PUT /provider/bookings/{id}/cancel`
|
||||
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 公開端點 Swagger 文件及共用 Schema
|
||||
|
||||
`app/Docs/PublicApiDoc.php` SHALL 文件化所有無需認證的公開端點,並定義全域共用 Schema。
|
||||
|
||||
#### Scenario: 共用 Schema 定義完整
|
||||
|
||||
- **WHEN** 執行 `php artisan l5-swagger:generate`
|
||||
- **THEN** 以下 Schema 出現在產生的 JSON:`DivingOffer`、`Review`、`CourseSchedule`、`PaginationMeta`、`ApiErrorResponse`
|
||||
|
||||
#### Scenario: GET /api/diving-offers 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** `GET /diving-offers` 端點顯示 query parameters(`q`、`region`、`tag`、`per_page`、`page`)及包含分頁 meta 的 response schema
|
||||
|
||||
#### Scenario: GET /api/diving-offers/{id} 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** `GET /diving-offers/{id}` 端點顯示 path parameter `id` 及包含 `cover_image_url`、`images` 陣列的 response schema;404 response 亦有定義
|
||||
|
||||
#### Scenario: GET /api/diving-offers/{id}/reviews 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 端點顯示 `sort`(helpful/rating/newest)、`page`、`per_page` 參數;response 包含 `summary`(average、total、distribution)與分頁 `meta`
|
||||
|
||||
#### Scenario: GET /api/diving-offers/{id}/schedules 文件化
|
||||
|
||||
- **WHEN** 開啟 Swagger UI
|
||||
- **THEN** 端點顯示 response 包含 `CourseSchedule` 陣列(id、date、period、capacity、booked_count、status)
|
||||
+4311
-18
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user