Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 855d9e1d4a | |||
| 981f753480 | |||
| 3b90edf843 | |||
| b06d8524db | |||
| 2a0e9255ae | |||
| b2e20767d8 | |||
| d9f0fe11c6 | |||
| a94c2462b8 | |||
| cb9c9a9385 | |||
| 5b13a014be | |||
| 8d1ea49abc | |||
| 9be50d493b | |||
| 0a6bfd9df8 | |||
| 127101adb3 | |||
| 0160d72467 | |||
| cfd0455597 | |||
| 8974a22584 | |||
| 9bde1ae344 | |||
| 3ad5972a5a | |||
| b770aab42a | |||
| 8d41f473e4 | |||
| 5463fd70b1 | |||
| 54dd4bb7a6 | |||
| 71b35533f4 | |||
| eff2dd273c | |||
| 4a5559a79e | |||
| 83e0e599a0 | |||
| 6fcf57a2d2 | |||
| 5ba598c78f | |||
| 3809ac7ed0 | |||
| 6b3a0816f5 | |||
| 1ba4ca893a | |||
| 6286399a3b | |||
| f80319109c | |||
| 4cf83375f3 | |||
| 91589a6f0e | |||
| ab24f210a6 | |||
| cc010b5c83 | |||
| c10d4ebfe6 | |||
| 153aee5f94 | |||
| f996a0053b | |||
| e7776fa930 | |||
| 972829419b | |||
| ecfb268d61 | |||
| d4821e527b | |||
| 75462c2619 | |||
| 12bd854dce | |||
| d6fdfb07f0 | |||
| 250c50fc74 | |||
| 856c342757 | |||
| 0814a3196f | |||
| 0db38ee30d | |||
| 8c54808cd8 | |||
| 3ddad32129 | |||
| 0dda2bb2a3 | |||
| b475a61d14 | |||
| a3462f8493 | |||
| 05ef8b9316 | |||
| 95bafef52d |
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Apply"
|
||||||
|
description: Implement tasks from an OpenSpec change (Experimental)
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, artifacts, experimental]
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement tasks from an OpenSpec change.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||||
|
|
||||||
|
2. **Check status to understand the schema**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||||
|
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||||
|
|
||||||
|
3. **Get apply instructions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspec instructions apply --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||||
|
- Progress (total, complete, remaining)
|
||||||
|
- Task list with status
|
||||||
|
- Dynamic instruction based on current state
|
||||||
|
|
||||||
|
**Handle states:**
|
||||||
|
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||||
|
- If `state: "all_done"`: congratulate, suggest archive
|
||||||
|
- Otherwise: proceed to implementation
|
||||||
|
|
||||||
|
4. **Read context files**
|
||||||
|
|
||||||
|
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||||
|
The files depend on the schema being used:
|
||||||
|
- **spec-driven**: proposal, specs, design, tasks
|
||||||
|
- Other schemas: follow the contextFiles from CLI output
|
||||||
|
|
||||||
|
5. **Show current progress**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Schema being used
|
||||||
|
- Progress: "N/M tasks complete"
|
||||||
|
- Remaining tasks overview
|
||||||
|
- Dynamic instruction from CLI
|
||||||
|
|
||||||
|
6. **Implement tasks (loop until done or blocked)**
|
||||||
|
|
||||||
|
For each pending task:
|
||||||
|
- Show which task is being worked on
|
||||||
|
- Make the code changes required
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||||
|
- Continue to next task
|
||||||
|
|
||||||
|
**Pause if:**
|
||||||
|
- Task is unclear → ask for clarification
|
||||||
|
- Implementation reveals a design issue → suggest updating artifacts
|
||||||
|
- Error or blocker encountered → report and wait for guidance
|
||||||
|
- User interrupts
|
||||||
|
|
||||||
|
7. **On completion or pause, show status**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Tasks completed this session
|
||||||
|
- Overall progress: "N/M tasks complete"
|
||||||
|
- If all done: suggest archive
|
||||||
|
- If paused: explain why and wait for guidance
|
||||||
|
|
||||||
|
**Output During Implementation**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementing: <change-name> (schema: <schema-name>)
|
||||||
|
|
||||||
|
Working on task 3/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
|
||||||
|
Working on task 4/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Completion**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 7/7 tasks complete ✓
|
||||||
|
|
||||||
|
### Completed This Session
|
||||||
|
- [x] Task 1
|
||||||
|
- [x] Task 2
|
||||||
|
...
|
||||||
|
|
||||||
|
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Pause (Issue Encountered)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Paused
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 4/7 tasks complete
|
||||||
|
|
||||||
|
### Issue Encountered
|
||||||
|
<description of the issue>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. <option 1>
|
||||||
|
2. <option 2>
|
||||||
|
3. Other approach
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Keep going through tasks until done or blocked
|
||||||
|
- Always read context files before starting (from the apply instructions output)
|
||||||
|
- If task is ambiguous, pause and ask before implementing
|
||||||
|
- If implementation reveals issues, pause and suggest artifact updates
|
||||||
|
- Keep code changes minimal and scoped to each task
|
||||||
|
- Update task checkbox immediately after completing each task
|
||||||
|
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||||
|
- Use contextFiles from CLI output, don't assume specific file names
|
||||||
|
|
||||||
|
**Fluid Workflow Integration**
|
||||||
|
|
||||||
|
This skill supports the "actions on a change" model:
|
||||||
|
|
||||||
|
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||||
|
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Archive"
|
||||||
|
description: Archive a completed change in the experimental workflow
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, archive, experimental]
|
||||||
|
---
|
||||||
|
|
||||||
|
Archive a completed change in the experimental workflow.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no change name provided, prompt for selection**
|
||||||
|
|
||||||
|
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||||
|
|
||||||
|
Show only active changes (not already archived).
|
||||||
|
Include the schema used for each change if available.
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||||
|
|
||||||
|
2. **Check artifact completion status**
|
||||||
|
|
||||||
|
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||||
|
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used
|
||||||
|
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||||
|
|
||||||
|
**If any artifacts are not `done`:**
|
||||||
|
- Display warning listing incomplete artifacts
|
||||||
|
- Prompt user for confirmation to continue
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
3. **Check task completion status**
|
||||||
|
|
||||||
|
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||||
|
|
||||||
|
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||||
|
|
||||||
|
**If incomplete tasks found:**
|
||||||
|
- Display warning showing count of incomplete tasks
|
||||||
|
- Prompt user for confirmation to continue
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
**If no tasks file exists:** Proceed without task-related warning.
|
||||||
|
|
||||||
|
4. **Assess delta spec sync state**
|
||||||
|
|
||||||
|
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||||
|
|
||||||
|
**If delta specs exist:**
|
||||||
|
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||||
|
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||||
|
- Show a combined summary before prompting
|
||||||
|
|
||||||
|
**Prompt options:**
|
||||||
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
Create the archive directory if it doesn't exist:
|
||||||
|
```bash
|
||||||
|
mkdir -p openspec/changes/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||||
|
|
||||||
|
**Check if target already exists:**
|
||||||
|
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||||
|
- If no: Move the change directory to archive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Display summary**
|
||||||
|
|
||||||
|
Show archive completion summary including:
|
||||||
|
- Change name
|
||||||
|
- Schema that was used
|
||||||
|
- Archive location
|
||||||
|
- Spec sync status (synced / sync skipped / no delta specs)
|
||||||
|
- Note about any warnings (incomplete artifacts/tasks)
|
||||||
|
|
||||||
|
**Output On Success**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** ✓ Synced to main specs
|
||||||
|
|
||||||
|
All artifacts complete. All tasks complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Success (No Delta Specs)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** No delta specs
|
||||||
|
|
||||||
|
All artifacts complete. All tasks complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Success With Warnings**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete (with warnings)
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** Sync skipped (user chose to skip)
|
||||||
|
|
||||||
|
**Warnings:**
|
||||||
|
- Archived with 2 incomplete artifacts
|
||||||
|
- Archived with 3 incomplete tasks
|
||||||
|
- Delta spec sync was skipped (user chose to skip)
|
||||||
|
|
||||||
|
Review the archive if this was not intentional.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Error (Archive Exists)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Failed
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
|
||||||
|
Target archive directory already exists.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. Rename the existing archive
|
||||||
|
2. Delete the existing archive if it's a duplicate
|
||||||
|
3. Wait until a different date to archive
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Always prompt for change selection if not provided
|
||||||
|
- Use artifact graph (openspec status --json) for completion checking
|
||||||
|
- Don't block archive on warnings - just inform and confirm
|
||||||
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
|
- Show clear summary of what happened
|
||||||
|
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||||
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Explore"
|
||||||
|
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, explore, experimental, thinking]
|
||||||
|
---
|
||||||
|
|
||||||
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
|
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||||
|
- A vague idea: "real-time collaboration"
|
||||||
|
- A specific problem: "the auth system is getting unwieldy"
|
||||||
|
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||||
|
- A comparison: "postgres vs sqlite for this"
|
||||||
|
- Nothing (just enter explore mode)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Stance
|
||||||
|
|
||||||
|
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||||
|
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||||
|
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||||
|
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||||
|
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||||
|
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Might Do
|
||||||
|
|
||||||
|
Depending on what the user brings, you might:
|
||||||
|
|
||||||
|
**Explore the problem space**
|
||||||
|
- Ask clarifying questions that emerge from what they said
|
||||||
|
- Challenge assumptions
|
||||||
|
- Reframe the problem
|
||||||
|
- Find analogies
|
||||||
|
|
||||||
|
**Investigate the codebase**
|
||||||
|
- Map existing architecture relevant to the discussion
|
||||||
|
- Find integration points
|
||||||
|
- Identify patterns already in use
|
||||||
|
- Surface hidden complexity
|
||||||
|
|
||||||
|
**Compare options**
|
||||||
|
- Brainstorm multiple approaches
|
||||||
|
- Build comparison tables
|
||||||
|
- Sketch tradeoffs
|
||||||
|
- Recommend a path (if asked)
|
||||||
|
|
||||||
|
**Visualize**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Use ASCII diagrams liberally │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ State │────────▶│ State │ │
|
||||||
|
│ │ A │ │ B │ │
|
||||||
|
│ └────────┘ └────────┘ │
|
||||||
|
│ │
|
||||||
|
│ System diagrams, state machines, │
|
||||||
|
│ data flows, architecture sketches, │
|
||||||
|
│ dependency graphs, comparison tables │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Surface risks and unknowns**
|
||||||
|
- Identify what could go wrong
|
||||||
|
- Find gaps in understanding
|
||||||
|
- Suggest spikes or investigations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenSpec Awareness
|
||||||
|
|
||||||
|
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||||
|
|
||||||
|
### Check for context
|
||||||
|
|
||||||
|
At the start, quickly check what exists:
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells you:
|
||||||
|
- If there are active changes
|
||||||
|
- Their names, schemas, and status
|
||||||
|
- What the user might be working on
|
||||||
|
|
||||||
|
If the user mentioned a specific change name, read its artifacts for context.
|
||||||
|
|
||||||
|
### When no change exists
|
||||||
|
|
||||||
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
|
### When a change exists
|
||||||
|
|
||||||
|
If the user mentions a change or you detect one is relevant:
|
||||||
|
|
||||||
|
1. **Read existing artifacts for context**
|
||||||
|
- `openspec/changes/<name>/proposal.md`
|
||||||
|
- `openspec/changes/<name>/design.md`
|
||||||
|
- `openspec/changes/<name>/tasks.md`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
2. **Reference them naturally in conversation**
|
||||||
|
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||||
|
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||||
|
|
||||||
|
3. **Offer to capture when decisions are made**
|
||||||
|
|
||||||
|
| Insight Type | Where to Capture |
|
||||||
|
|----------------------------|--------------------------------|
|
||||||
|
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||||
|
| Requirement changed | `specs/<capability>/spec.md` |
|
||||||
|
| Design decision made | `design.md` |
|
||||||
|
| Scope changed | `proposal.md` |
|
||||||
|
| New work identified | `tasks.md` |
|
||||||
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
|
Example offers:
|
||||||
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
- "This is a new requirement. Add it to specs?"
|
||||||
|
- "This changes scope. Update the proposal?"
|
||||||
|
|
||||||
|
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Don't Have To Do
|
||||||
|
|
||||||
|
- Follow a script
|
||||||
|
- Ask the same questions every time
|
||||||
|
- Produce a specific artifact
|
||||||
|
- Reach a conclusion
|
||||||
|
- Stay on topic if a tangent is valuable
|
||||||
|
- Be brief (this is thinking time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending Discovery
|
||||||
|
|
||||||
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|
||||||
|
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||||
|
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||||
|
- **Don't rush** - Discovery is thinking time, not task time
|
||||||
|
- **Don't force structure** - Let patterns emerge naturally
|
||||||
|
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||||
|
- **Do visualize** - A good diagram is worth many paragraphs
|
||||||
|
- **Do explore the codebase** - Ground discussions in reality
|
||||||
|
- **Do question assumptions** - Including the user's and your own
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
name: "OPSX: Propose"
|
||||||
|
description: Propose a new change - create it and generate all artifacts in one step
|
||||||
|
category: Workflow
|
||||||
|
tags: [workflow, artifacts, experimental]
|
||||||
|
---
|
||||||
|
|
||||||
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no input provided, ask what they want to build**
|
||||||
|
|
||||||
|
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||||
|
> "What change do you want to work on? Describe what you want to build or fix."
|
||||||
|
|
||||||
|
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||||
|
|
||||||
|
2. **Create the change directory**
|
||||||
|
```bash
|
||||||
|
openspec new change "<name>"
|
||||||
|
```
|
||||||
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
|
3. **Get the artifact build order**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to get:
|
||||||
|
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||||
|
- `artifacts`: list of all artifacts with their status and dependencies
|
||||||
|
|
||||||
|
4. **Create artifacts in sequence until apply-ready**
|
||||||
|
|
||||||
|
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||||
|
|
||||||
|
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||||
|
|
||||||
|
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||||
|
- Get instructions:
|
||||||
|
```bash
|
||||||
|
openspec instructions <artifact-id> --change "<name>" --json
|
||||||
|
```
|
||||||
|
- The instructions JSON includes:
|
||||||
|
- `context`: Project background (constraints for you - do NOT include in output)
|
||||||
|
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||||
|
- `template`: The structure to use for your output file
|
||||||
|
- `instruction`: Schema-specific guidance for this artifact type
|
||||||
|
- `outputPath`: Where to write the artifact
|
||||||
|
- `dependencies`: Completed artifacts to read for context
|
||||||
|
- Read any completed dependency files for context
|
||||||
|
- Create the artifact file using `template` as the structure
|
||||||
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
|
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||||
|
- Stop when all `applyRequires` artifacts are done
|
||||||
|
|
||||||
|
c. **If an artifact requires user input** (unclear context):
|
||||||
|
- Use **AskUserQuestion tool** to clarify
|
||||||
|
- Then continue with creation
|
||||||
|
|
||||||
|
5. **Show final status**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
After completing all artifacts, summarize:
|
||||||
|
- Change name and location
|
||||||
|
- List of artifacts created with brief descriptions
|
||||||
|
- What's ready: "All artifacts created! Ready for implementation."
|
||||||
|
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||||
|
|
||||||
|
**Artifact Creation Guidelines**
|
||||||
|
|
||||||
|
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||||
|
- The schema defines what each artifact should contain - follow it
|
||||||
|
- Read dependency artifacts for context before creating new ones
|
||||||
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
|
- Always read dependency artifacts before creating a new one
|
||||||
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: openspec-apply-change
|
||||||
|
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement tasks from an OpenSpec change.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||||
|
|
||||||
|
2. **Check status to understand the schema**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||||
|
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||||
|
|
||||||
|
3. **Get apply instructions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspec instructions apply --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||||
|
- Progress (total, complete, remaining)
|
||||||
|
- Task list with status
|
||||||
|
- Dynamic instruction based on current state
|
||||||
|
|
||||||
|
**Handle states:**
|
||||||
|
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||||
|
- If `state: "all_done"`: congratulate, suggest archive
|
||||||
|
- Otherwise: proceed to implementation
|
||||||
|
|
||||||
|
4. **Read context files**
|
||||||
|
|
||||||
|
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||||
|
The files depend on the schema being used:
|
||||||
|
- **spec-driven**: proposal, specs, design, tasks
|
||||||
|
- Other schemas: follow the contextFiles from CLI output
|
||||||
|
|
||||||
|
5. **Show current progress**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Schema being used
|
||||||
|
- Progress: "N/M tasks complete"
|
||||||
|
- Remaining tasks overview
|
||||||
|
- Dynamic instruction from CLI
|
||||||
|
|
||||||
|
6. **Implement tasks (loop until done or blocked)**
|
||||||
|
|
||||||
|
For each pending task:
|
||||||
|
- Show which task is being worked on
|
||||||
|
- Make the code changes required
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||||
|
- Continue to next task
|
||||||
|
|
||||||
|
**Pause if:**
|
||||||
|
- Task is unclear → ask for clarification
|
||||||
|
- Implementation reveals a design issue → suggest updating artifacts
|
||||||
|
- Error or blocker encountered → report and wait for guidance
|
||||||
|
- User interrupts
|
||||||
|
|
||||||
|
7. **On completion or pause, show status**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Tasks completed this session
|
||||||
|
- Overall progress: "N/M tasks complete"
|
||||||
|
- If all done: suggest archive
|
||||||
|
- If paused: explain why and wait for guidance
|
||||||
|
|
||||||
|
**Output During Implementation**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementing: <change-name> (schema: <schema-name>)
|
||||||
|
|
||||||
|
Working on task 3/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
|
||||||
|
Working on task 4/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Completion**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 7/7 tasks complete ✓
|
||||||
|
|
||||||
|
### Completed This Session
|
||||||
|
- [x] Task 1
|
||||||
|
- [x] Task 2
|
||||||
|
...
|
||||||
|
|
||||||
|
All tasks complete! Ready to archive this change.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Pause (Issue Encountered)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Paused
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 4/7 tasks complete
|
||||||
|
|
||||||
|
### Issue Encountered
|
||||||
|
<description of the issue>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. <option 1>
|
||||||
|
2. <option 2>
|
||||||
|
3. Other approach
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Keep going through tasks until done or blocked
|
||||||
|
- Always read context files before starting (from the apply instructions output)
|
||||||
|
- If task is ambiguous, pause and ask before implementing
|
||||||
|
- If implementation reveals issues, pause and suggest artifact updates
|
||||||
|
- Keep code changes minimal and scoped to each task
|
||||||
|
- Update task checkbox immediately after completing each task
|
||||||
|
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||||
|
- Use contextFiles from CLI output, don't assume specific file names
|
||||||
|
|
||||||
|
**Fluid Workflow Integration**
|
||||||
|
|
||||||
|
This skill supports the "actions on a change" model:
|
||||||
|
|
||||||
|
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||||
|
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: openspec-archive-change
|
||||||
|
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Archive a completed change in the experimental workflow.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no change name provided, prompt for selection**
|
||||||
|
|
||||||
|
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||||
|
|
||||||
|
Show only active changes (not already archived).
|
||||||
|
Include the schema used for each change if available.
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||||
|
|
||||||
|
2. **Check artifact completion status**
|
||||||
|
|
||||||
|
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||||
|
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used
|
||||||
|
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||||
|
|
||||||
|
**If any artifacts are not `done`:**
|
||||||
|
- Display warning listing incomplete artifacts
|
||||||
|
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
3. **Check task completion status**
|
||||||
|
|
||||||
|
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||||
|
|
||||||
|
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||||
|
|
||||||
|
**If incomplete tasks found:**
|
||||||
|
- Display warning showing count of incomplete tasks
|
||||||
|
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
**If no tasks file exists:** Proceed without task-related warning.
|
||||||
|
|
||||||
|
4. **Assess delta spec sync state**
|
||||||
|
|
||||||
|
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||||
|
|
||||||
|
**If delta specs exist:**
|
||||||
|
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||||
|
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||||
|
- Show a combined summary before prompting
|
||||||
|
|
||||||
|
**Prompt options:**
|
||||||
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
Create the archive directory if it doesn't exist:
|
||||||
|
```bash
|
||||||
|
mkdir -p openspec/changes/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||||
|
|
||||||
|
**Check if target already exists:**
|
||||||
|
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||||
|
- If no: Move the change directory to archive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Display summary**
|
||||||
|
|
||||||
|
Show archive completion summary including:
|
||||||
|
- Change name
|
||||||
|
- Schema that was used
|
||||||
|
- Archive location
|
||||||
|
- Whether specs were synced (if applicable)
|
||||||
|
- Note about any warnings (incomplete artifacts/tasks)
|
||||||
|
|
||||||
|
**Output On Success**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||||
|
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||||
|
|
||||||
|
All artifacts complete. All tasks complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Always prompt for change selection if not provided
|
||||||
|
- Use artifact graph (openspec status --json) for completion checking
|
||||||
|
- Don't block archive on warnings - just inform and confirm
|
||||||
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
|
- Show clear summary of what happened
|
||||||
|
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||||
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
name: openspec-explore
|
||||||
|
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Stance
|
||||||
|
|
||||||
|
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||||
|
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||||
|
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||||
|
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||||
|
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||||
|
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Might Do
|
||||||
|
|
||||||
|
Depending on what the user brings, you might:
|
||||||
|
|
||||||
|
**Explore the problem space**
|
||||||
|
- Ask clarifying questions that emerge from what they said
|
||||||
|
- Challenge assumptions
|
||||||
|
- Reframe the problem
|
||||||
|
- Find analogies
|
||||||
|
|
||||||
|
**Investigate the codebase**
|
||||||
|
- Map existing architecture relevant to the discussion
|
||||||
|
- Find integration points
|
||||||
|
- Identify patterns already in use
|
||||||
|
- Surface hidden complexity
|
||||||
|
|
||||||
|
**Compare options**
|
||||||
|
- Brainstorm multiple approaches
|
||||||
|
- Build comparison tables
|
||||||
|
- Sketch tradeoffs
|
||||||
|
- Recommend a path (if asked)
|
||||||
|
|
||||||
|
**Visualize**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Use ASCII diagrams liberally │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ State │────────▶│ State │ │
|
||||||
|
│ │ A │ │ B │ │
|
||||||
|
│ └────────┘ └────────┘ │
|
||||||
|
│ │
|
||||||
|
│ System diagrams, state machines, │
|
||||||
|
│ data flows, architecture sketches, │
|
||||||
|
│ dependency graphs, comparison tables │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Surface risks and unknowns**
|
||||||
|
- Identify what could go wrong
|
||||||
|
- Find gaps in understanding
|
||||||
|
- Suggest spikes or investigations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenSpec Awareness
|
||||||
|
|
||||||
|
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||||
|
|
||||||
|
### Check for context
|
||||||
|
|
||||||
|
At the start, quickly check what exists:
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells you:
|
||||||
|
- If there are active changes
|
||||||
|
- Their names, schemas, and status
|
||||||
|
- What the user might be working on
|
||||||
|
|
||||||
|
### When no change exists
|
||||||
|
|
||||||
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
|
### When a change exists
|
||||||
|
|
||||||
|
If the user mentions a change or you detect one is relevant:
|
||||||
|
|
||||||
|
1. **Read existing artifacts for context**
|
||||||
|
- `openspec/changes/<name>/proposal.md`
|
||||||
|
- `openspec/changes/<name>/design.md`
|
||||||
|
- `openspec/changes/<name>/tasks.md`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
2. **Reference them naturally in conversation**
|
||||||
|
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||||
|
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||||
|
|
||||||
|
3. **Offer to capture when decisions are made**
|
||||||
|
|
||||||
|
| Insight Type | Where to Capture |
|
||||||
|
|----------------------------|--------------------------------|
|
||||||
|
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||||
|
| Requirement changed | `specs/<capability>/spec.md` |
|
||||||
|
| Design decision made | `design.md` |
|
||||||
|
| Scope changed | `proposal.md` |
|
||||||
|
| New work identified | `tasks.md` |
|
||||||
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
|
Example offers:
|
||||||
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
- "This is a new requirement. Add it to specs?"
|
||||||
|
- "This changes scope. Update the proposal?"
|
||||||
|
|
||||||
|
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Don't Have To Do
|
||||||
|
|
||||||
|
- Follow a script
|
||||||
|
- Ask the same questions every time
|
||||||
|
- Produce a specific artifact
|
||||||
|
- Reach a conclusion
|
||||||
|
- Stay on topic if a tangent is valuable
|
||||||
|
- Be brief (this is thinking time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handling Different Entry Points
|
||||||
|
|
||||||
|
**User brings a vague idea:**
|
||||||
|
```
|
||||||
|
User: I'm thinking about adding real-time collaboration
|
||||||
|
|
||||||
|
You: Real-time collab is a big space. Let me think about this...
|
||||||
|
|
||||||
|
COLLABORATION SPECTRUM
|
||||||
|
════════════════════════════════════════════
|
||||||
|
|
||||||
|
Awareness Coordination Sync
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────┐ ┌────────┐ ┌────────┐
|
||||||
|
│Presence│ │Cursors │ │ CRDT │
|
||||||
|
│ "3 │ │ Multi │ │Conflict│
|
||||||
|
│online" │ │ select │ │ free │
|
||||||
|
└────────┘ └────────┘ └────────┘
|
||||||
|
│ │ │
|
||||||
|
trivial moderate complex
|
||||||
|
|
||||||
|
Where's your head at?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User brings a specific problem:**
|
||||||
|
```
|
||||||
|
User: The auth system is a mess
|
||||||
|
|
||||||
|
You: [reads codebase]
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ CURRENT AUTH FLOW │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────┼─────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ Google │ │ GitHub │ │ Email │
|
||||||
|
│ OAuth │ │ OAuth │ │ Magic │
|
||||||
|
└────┬────┘ └────┬────┘ └────┬────┘
|
||||||
|
│ │ │
|
||||||
|
└─────────────┼─────────────┘
|
||||||
|
▼
|
||||||
|
┌───────────┐
|
||||||
|
│ Session │
|
||||||
|
└─────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────┐
|
||||||
|
│ Perms │
|
||||||
|
└───────────┘
|
||||||
|
|
||||||
|
I see three tangles. Which one's burning?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User is stuck mid-implementation:**
|
||||||
|
```
|
||||||
|
User: /opsx:explore add-auth-system
|
||||||
|
The OAuth integration is more complex than expected
|
||||||
|
|
||||||
|
You: [reads change artifacts]
|
||||||
|
|
||||||
|
You're on task 4: "Implement OAuth flow"
|
||||||
|
|
||||||
|
Let me trace what's involved...
|
||||||
|
|
||||||
|
[draws diagram, explores options, suggests paths]
|
||||||
|
|
||||||
|
Want to update the design to reflect this?
|
||||||
|
Or add a spike task to investigate?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User wants to compare options:**
|
||||||
|
```
|
||||||
|
User: Should we use Postgres or SQLite?
|
||||||
|
|
||||||
|
You: Generic answer is boring. What's the context?
|
||||||
|
|
||||||
|
User: A CLI tool that tracks local dev environments
|
||||||
|
|
||||||
|
You: That changes everything.
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ CLI TOOL DATA STORAGE │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Key constraints:
|
||||||
|
• No daemon running
|
||||||
|
• Must work offline
|
||||||
|
• Single user
|
||||||
|
|
||||||
|
SQLite Postgres
|
||||||
|
Deployment embedded ✓ needs server ✗
|
||||||
|
Offline yes ✓ no ✗
|
||||||
|
Single file yes ✓ no ✗
|
||||||
|
|
||||||
|
SQLite. Not even close.
|
||||||
|
|
||||||
|
Unless... is there a sync component?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending Discovery
|
||||||
|
|
||||||
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|
||||||
|
When it feels like things are crystallizing, you might summarize:
|
||||||
|
|
||||||
|
```
|
||||||
|
## What We Figured Out
|
||||||
|
|
||||||
|
**The problem**: [crystallized understanding]
|
||||||
|
|
||||||
|
**The approach**: [if one emerged]
|
||||||
|
|
||||||
|
**Open questions**: [if any remain]
|
||||||
|
|
||||||
|
**Next steps** (if ready):
|
||||||
|
- Create a change proposal
|
||||||
|
- Keep exploring: just keep talking
|
||||||
|
```
|
||||||
|
|
||||||
|
But this summary is optional. Sometimes the thinking IS the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||||
|
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||||
|
- **Don't rush** - Discovery is thinking time, not task time
|
||||||
|
- **Don't force structure** - Let patterns emerge naturally
|
||||||
|
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||||
|
- **Do visualize** - A good diagram is worth many paragraphs
|
||||||
|
- **Do explore the codebase** - Ground discussions in reality
|
||||||
|
- **Do question assumptions** - Including the user's and your own
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: openspec-propose
|
||||||
|
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.3.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **If no clear input provided, ask what they want to build**
|
||||||
|
|
||||||
|
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||||
|
> "What change do you want to work on? Describe what you want to build or fix."
|
||||||
|
|
||||||
|
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||||
|
|
||||||
|
2. **Create the change directory**
|
||||||
|
```bash
|
||||||
|
openspec new change "<name>"
|
||||||
|
```
|
||||||
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
|
3. **Get the artifact build order**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to get:
|
||||||
|
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||||
|
- `artifacts`: list of all artifacts with their status and dependencies
|
||||||
|
|
||||||
|
4. **Create artifacts in sequence until apply-ready**
|
||||||
|
|
||||||
|
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||||
|
|
||||||
|
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||||
|
|
||||||
|
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||||
|
- Get instructions:
|
||||||
|
```bash
|
||||||
|
openspec instructions <artifact-id> --change "<name>" --json
|
||||||
|
```
|
||||||
|
- The instructions JSON includes:
|
||||||
|
- `context`: Project background (constraints for you - do NOT include in output)
|
||||||
|
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||||
|
- `template`: The structure to use for your output file
|
||||||
|
- `instruction`: Schema-specific guidance for this artifact type
|
||||||
|
- `outputPath`: Where to write the artifact
|
||||||
|
- `dependencies`: Completed artifacts to read for context
|
||||||
|
- Read any completed dependency files for context
|
||||||
|
- Create the artifact file using `template` as the structure
|
||||||
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
|
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||||
|
- Stop when all `applyRequires` artifacts are done
|
||||||
|
|
||||||
|
c. **If an artifact requires user input** (unclear context):
|
||||||
|
- Use **AskUserQuestion tool** to clarify
|
||||||
|
- Then continue with creation
|
||||||
|
|
||||||
|
5. **Show final status**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
After completing all artifacts, summarize:
|
||||||
|
- Change name and location
|
||||||
|
- List of artifacts created with brief descriptions
|
||||||
|
- What's ready: "All artifacts created! Ready for implementation."
|
||||||
|
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||||
|
|
||||||
|
**Artifact Creation Guidelines**
|
||||||
|
|
||||||
|
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||||
|
- The schema defines what each artifact should contain - follow it
|
||||||
|
- Read dependency artifacts for context before creating new ones
|
||||||
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
|
- Always read dependency artifacts before creating a new one
|
||||||
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
@@ -64,3 +64,6 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
SENTRY_LARAVEL_DSN=
|
||||||
|
SENTRY_ENVIRONMENT="${APP_ENV}"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Pull latest code
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
flock /tmp/cfdive-deploy.lock git fetch origin
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app php artisan migrate --force
|
||||||
|
|
||||||
|
- name: Cache configs
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose exec -T app php artisan config:cache
|
||||||
|
docker compose exec -T app php artisan route:cache
|
||||||
|
docker compose exec -T app php artisan view:cache
|
||||||
|
docker compose exec -T app php artisan event:cache
|
||||||
|
|
||||||
|
- name: Restart queue worker
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
docker compose restart queue-worker
|
||||||
|
|
||||||
|
- name: Rebuild frontend (if changed)
|
||||||
|
run: |
|
||||||
|
cd /root/myproject/CFDivePlatform
|
||||||
|
if git diff --name-only HEAD~1 HEAD | grep -q '^frontend/'; then
|
||||||
|
docker compose build frontend
|
||||||
|
docker compose up -d frontend
|
||||||
|
fi
|
||||||
@@ -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
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
/.claude
|
/.claude/settings.local.json
|
||||||
|
/.claude/worktrees/
|
||||||
/.opensp
|
/.opensp
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
|
|||||||
+5
-1
@@ -7,6 +7,9 @@ RUN apt-get update && apt-get install -y \
|
|||||||
git \
|
git \
|
||||||
curl \
|
curl \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libwebp-dev \
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
zip \
|
zip \
|
||||||
@@ -21,7 +24,8 @@ RUN apt-get update && apt-get install -y \
|
|||||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 安裝 PHP 擴展
|
# 安裝 PHP 擴展
|
||||||
# 這些擴展是 Laravel 和一般 PHP 開發所需的
|
# GD 需要在 install 前先 configure,才能帶入 jpeg/webp/freetype 支援
|
||||||
|
RUN docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype
|
||||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||||
|
|
||||||
# 從官方 Composer 鏡像複製 Composer 執行文件
|
# 從官方 Composer 鏡像複製 Composer 執行文件
|
||||||
|
|||||||
@@ -1,4 +1,91 @@
|
|||||||

|
# CFDive Platform
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
|
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、即時訊息、評價與通知等完整服務。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能概覽
|
||||||
|
|
||||||
|
**會員(Member)**
|
||||||
|
- 註冊 / 登入(Email + Google OAuth)
|
||||||
|
- 瀏覽、搜尋、篩選潛水課程
|
||||||
|
- 查看課程時段並送出預約
|
||||||
|
- 與教練即時訊息(文字 + 圖片,含已讀回執)
|
||||||
|
- 對完成的課程留下評價(支援匿名、有幫助投票)
|
||||||
|
- 站內通知(Bell Icon 即時更新 + 瀏覽器推播)
|
||||||
|
|
||||||
|
**教練(Provider)**
|
||||||
|
- 課程 CRUD(含封面 + 相簿圖片上傳)
|
||||||
|
- 課程時段管理
|
||||||
|
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||||
|
- 與學員即時訊息
|
||||||
|
|
||||||
|
**管理員(Admin)**
|
||||||
|
- 平台統計數據(會員數、教練數、課程數)
|
||||||
|
- 會員與教練帳號管理(啟用 / 停用 / 審核)
|
||||||
|
- 課程、預約、評價管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術棧
|
||||||
|
|
||||||
|
| 層級 | 技術 |
|
||||||
|
|------|------|
|
||||||
|
| 後端 | PHP 8.x / Laravel 11 |
|
||||||
|
| 前端 | Vue 3 + Vite + Tailwind CSS |
|
||||||
|
| 資料庫 | MySQL 8.0 |
|
||||||
|
| 快取 | Redis |
|
||||||
|
| 即時通訊 | Laravel Reverb(WebSocket,`wss://ws.hank-space.com`)|
|
||||||
|
| 認證 | Laravel Sanctum + Google OAuth |
|
||||||
|
| 容器 | Docker / Docker Compose |
|
||||||
|
| API 文件 | Swagger UI(l5-swagger)|
|
||||||
|
| 錯誤監控 | Sentry |
|
||||||
|
| CI/CD | Gitea Actions(自動部署至 VPS)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 文件
|
||||||
|
|
||||||
|
共 73 個端點,涵蓋:
|
||||||
|
- 認證(Email + Google OAuth)
|
||||||
|
- 公開課程查詢
|
||||||
|
- 會員預約 / 訊息 / 評價 / 通知
|
||||||
|
- 教練課程 / 時段 / 預約管理
|
||||||
|
- 管理員後台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 健康檢查
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
回傳 DB、Redis、Cache 狀態,供 UptimeRobot 監控使用。全部正常回 200,任一異常回 503。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地開發
|
||||||
|
|
||||||
|
**啟動**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
| 服務 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| API / 前端 | http://localhost:8080 |
|
||||||
|
| phpMyAdmin | http://localhost:8081 |
|
||||||
|
| Mailpit | http://localhost:8025 |
|
||||||
|
| Reverb WebSocket | ws://localhost:8085 |
|
||||||
|
|
||||||
|
**環境設定**
|
||||||
|
|
||||||
|
複製 `.env.example` 為 `.env`,填入以下必要值:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REVERB_APP_KEY= # 32 字元隨機字串
|
||||||
|
REVERB_APP_SECRET= # 32 字元隨機字串
|
||||||
|
SENTRY_LARAVEL_DSN= # Sentry DSN(留空則停用)
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Broadcasting;
|
||||||
|
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class BookingPresenceChannel
|
||||||
|
{
|
||||||
|
public function join(User $user, Booking $booking): array|false
|
||||||
|
{
|
||||||
|
if ($booking->status->value !== 'confirmed') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$booking->loadMissing('schedule');
|
||||||
|
|
||||||
|
$isMember = $user->role === 'member' && $booking->member_id === $user->id;
|
||||||
|
$isProvider = $user->role === 'provider'
|
||||||
|
&& $booking->schedule !== null
|
||||||
|
&& $booking->schedule->provider_id === $user->id;
|
||||||
|
|
||||||
|
if (!$isMember && !$isProvider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_type' => $user->role,
|
||||||
|
'name' => $user->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 統計",
|
||||||
|
* description="管理員平台統計"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 會員管理",
|
||||||
|
* description="管理員的會員帳號管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 教練管理",
|
||||||
|
* description="管理員的服務提供者帳號管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin 課程管理",
|
||||||
|
* description="管理員的課程、預約、評價管理"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class AdminApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Stats
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得平台統計數據
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/stats",
|
||||||
|
* summary="取得平台統計數據",
|
||||||
|
* description="回傳會員總數、服務提供者總數、課程總數;非 admin 角色回傳 403",
|
||||||
|
* operationId="getAdminStats",
|
||||||
|
* tags={"Admin 統計"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="total_members", type="integer", example=128),
|
||||||
|
* @OA\Property(property="total_providers", type="integer", example=34),
|
||||||
|
* @OA\Property(property="total_offers", type="integer", example=87)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getAdminStats()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Member Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得會員列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/members",
|
||||||
|
* summary="取得會員列表",
|
||||||
|
* description="分頁回傳所有會員帳號,含 member_profile",
|
||||||
|
* operationId="listAdminMembers",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="name", type="string", example="王小明"),
|
||||||
|
* @OA\Property(property="email", type="string", example="member@example.com"),
|
||||||
|
* @OA\Property(property="role", type="string", example="member"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||||
|
* @OA\Property(property="member_profile", type="object", nullable=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminMembers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一會員
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/members/{id}",
|
||||||
|
* summary="取得單一會員",
|
||||||
|
* operationId="getAdminMember",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="object")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getAdminMember()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切換會員啟用狀態
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/members/{id}/toggle-active",
|
||||||
|
* summary="切換會員啟用狀態",
|
||||||
|
* description="啟用或停用指定會員帳號",
|
||||||
|
* operationId="toggleMemberActive",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="切換成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="帳號已停用"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function toggleMemberActive()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認會員存在
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/check-member/{id}",
|
||||||
|
* summary="確認會員存在",
|
||||||
|
* description="快速確認指定 ID 的會員是否存在(角色為 member)",
|
||||||
|
* operationId="checkMember",
|
||||||
|
* tags={"Admin 會員管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="會員存在",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="exists", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function checkMember()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Provider Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得教練列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/providers",
|
||||||
|
* summary="取得教練列表",
|
||||||
|
* description="分頁回傳所有服務提供者,含 provider_profile",
|
||||||
|
* operationId="listAdminProviders",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=2),
|
||||||
|
* @OA\Property(property="name", type="string", example="林教練"),
|
||||||
|
* @OA\Property(property="email", type="string", example="coach@example.com"),
|
||||||
|
* @OA\Property(property="role", type="string", example="provider"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="is_verified", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="provider_profile", type="object", nullable=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminProviders()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一教練
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/providers/{id}",
|
||||||
|
* summary="取得單一教練",
|
||||||
|
* operationId="getAdminProvider",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="object")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getAdminProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切換教練啟用狀態
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/providers/{id}/toggle-active",
|
||||||
|
* summary="切換教練啟用狀態",
|
||||||
|
* operationId="toggleProviderActive",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="切換成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="帳號已停用"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function toggleProviderActive()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切換教練審核狀態
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/providers/{id}/toggle-verified",
|
||||||
|
* summary="切換教練審核狀態",
|
||||||
|
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
|
||||||
|
* operationId="toggleProviderVerified",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="切換成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="教練已通過審核"),
|
||||||
|
* @OA\Property(property="is_verified", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function toggleProviderVerified()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認教練存在
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/check-provider/{id}",
|
||||||
|
* summary="確認教練存在",
|
||||||
|
* operationId="checkProvider",
|
||||||
|
* tags={"Admin 教練管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="查詢成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="exists", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function checkProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Admin Offers / Bookings / Reviews
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得所有課程(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/offers",
|
||||||
|
* summary="取得所有課程(Admin)",
|
||||||
|
* description="分頁回傳全平台課程列表",
|
||||||
|
* operationId="listAdminOffers",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminOffers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除課程(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/admin/offers/{id}",
|
||||||
|
* summary="刪除課程(Admin)",
|
||||||
|
* operationId="deleteAdminOffer",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteAdminOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得所有預約(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/bookings",
|
||||||
|
* summary="取得所有預約(Admin)",
|
||||||
|
* description="分頁回傳全平台預約列表",
|
||||||
|
* operationId="listAdminBookings",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminBookings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記預約完成(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/admin/bookings/{id}/complete",
|
||||||
|
* summary="標記預約完成(Admin)",
|
||||||
|
* operationId="completeBookingByAdmin",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function completeBookingByAdmin()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得所有評價(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/admin/reviews",
|
||||||
|
* summary="取得所有評價(Admin)",
|
||||||
|
* description="分頁回傳全平台評價列表,per_page 最大 100",
|
||||||
|
* operationId="listAdminReviews",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(最大 100)", @OA\Schema(type="integer", default=20, maximum=100)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Review")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listAdminReviews()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除評價(Admin)
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/admin/reviews/{id}",
|
||||||
|
* summary="刪除評價(Admin)",
|
||||||
|
* operationId="deleteAdminReview",
|
||||||
|
* tags={"Admin 課程管理"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteAdminReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-21
@@ -35,6 +35,10 @@ use OpenApi\Annotations as OA;
|
|||||||
* name="管理員",
|
* name="管理員",
|
||||||
* description="管理員相關操作"
|
* description="管理員相關操作"
|
||||||
* )
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="認證",
|
||||||
|
* description="通用認證操作(登出、取得當前使用者)"
|
||||||
|
* )
|
||||||
*/
|
*/
|
||||||
class AuthApiDoc
|
class AuthApiDoc
|
||||||
{
|
{
|
||||||
@@ -42,7 +46,7 @@ class AuthApiDoc
|
|||||||
* 會員註冊
|
* 會員註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/member",
|
* path="/member/register",
|
||||||
* summary="會員註冊",
|
* summary="會員註冊",
|
||||||
* description="建立新的會員帳號",
|
* description="建立新的會員帳號",
|
||||||
* operationId="registerMember",
|
* operationId="registerMember",
|
||||||
@@ -119,7 +123,7 @@ class AuthApiDoc
|
|||||||
* 會員登入
|
* 會員登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/member",
|
* path="/member/login",
|
||||||
* summary="會員登入",
|
* summary="會員登入",
|
||||||
* description="會員帳號登入系統",
|
* description="會員帳號登入系統",
|
||||||
* operationId="loginMember",
|
* operationId="loginMember",
|
||||||
@@ -207,7 +211,7 @@ class AuthApiDoc
|
|||||||
* 會員登出
|
* 會員登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/member",
|
* path="/member/logout",
|
||||||
* summary="會員登出",
|
* summary="會員登出",
|
||||||
* description="會員登出系統並撤銷當前令牌",
|
* description="會員登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutMember",
|
* operationId="logoutMember",
|
||||||
@@ -241,7 +245,7 @@ class AuthApiDoc
|
|||||||
* 取得會員個人資料
|
* 取得會員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/member",
|
* path="/member/profile",
|
||||||
* summary="取得會員個人資料",
|
* summary="取得會員個人資料",
|
||||||
* description="取得當前登入會員的個人資料",
|
* description="取得當前登入會員的個人資料",
|
||||||
* operationId="memberProfile",
|
* operationId="memberProfile",
|
||||||
@@ -303,7 +307,7 @@ class AuthApiDoc
|
|||||||
* 更新會員個人資料
|
* 更新會員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/member",
|
* path="/member/profile",
|
||||||
* summary="更新會員個人資料",
|
* summary="更新會員個人資料",
|
||||||
* description="更新當前登入會員的個人資料",
|
* description="更新當前登入會員的個人資料",
|
||||||
* operationId="updateMemberProfile",
|
* operationId="updateMemberProfile",
|
||||||
@@ -383,8 +387,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改會員密碼
|
* 修改會員密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/member",
|
* path="/member/change-password",
|
||||||
* summary="修改會員密碼",
|
* summary="修改會員密碼",
|
||||||
* description="修改當前登入會員的密碼",
|
* description="修改當前登入會員的密碼",
|
||||||
* operationId="changeMemberPassword",
|
* operationId="changeMemberPassword",
|
||||||
@@ -444,7 +448,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者註冊
|
* 服務提供者註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/provider",
|
* path="/provider/register",
|
||||||
* summary="服務提供者註冊",
|
* summary="服務提供者註冊",
|
||||||
* description="建立新的服務提供者帳號",
|
* description="建立新的服務提供者帳號",
|
||||||
* operationId="registerProvider",
|
* operationId="registerProvider",
|
||||||
@@ -498,7 +502,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者登入
|
* 服務提供者登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/provider",
|
* path="/provider/login",
|
||||||
* summary="服務提供者登入",
|
* summary="服務提供者登入",
|
||||||
* description="服務提供者帳號登入系統",
|
* description="服務提供者帳號登入系統",
|
||||||
* operationId="loginProvider",
|
* operationId="loginProvider",
|
||||||
@@ -580,7 +584,7 @@ class AuthApiDoc
|
|||||||
* 服務提供者登出
|
* 服務提供者登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/provider",
|
* path="/provider/logout",
|
||||||
* summary="服務提供者登出",
|
* summary="服務提供者登出",
|
||||||
* description="服務提供者登出系統並撤銷當前令牌",
|
* description="服務提供者登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutProvider",
|
* operationId="logoutProvider",
|
||||||
@@ -614,7 +618,7 @@ class AuthApiDoc
|
|||||||
* 取得服務提供者資料
|
* 取得服務提供者資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/provider",
|
* path="/provider/profile",
|
||||||
* summary="取得服務提供者資料",
|
* summary="取得服務提供者資料",
|
||||||
* description="取得當前登入服務提供者的資料",
|
* description="取得當前登入服務提供者的資料",
|
||||||
* operationId="providerProfile",
|
* operationId="providerProfile",
|
||||||
@@ -678,7 +682,7 @@ class AuthApiDoc
|
|||||||
* 更新服務提供者資料
|
* 更新服務提供者資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/provider",
|
* path="/provider/profile",
|
||||||
* summary="更新服務提供者資料",
|
* summary="更新服務提供者資料",
|
||||||
* description="更新當前登入服務提供者的資料",
|
* description="更新當前登入服務提供者的資料",
|
||||||
* operationId="updateProviderProfile",
|
* operationId="updateProviderProfile",
|
||||||
@@ -764,8 +768,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改服務提供者密碼
|
* 修改服務提供者密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/provider",
|
* path="/provider/change-password",
|
||||||
* summary="修改服務提供者密碼",
|
* summary="修改服務提供者密碼",
|
||||||
* description="修改當前登入服務提供者的密碼",
|
* description="修改當前登入服務提供者的密碼",
|
||||||
* operationId="changeProviderPassword",
|
* operationId="changeProviderPassword",
|
||||||
@@ -825,7 +829,7 @@ class AuthApiDoc
|
|||||||
* 管理員註冊
|
* 管理員註冊
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/register/admin",
|
* path="/admin/register",
|
||||||
* summary="管理員註冊",
|
* summary="管理員註冊",
|
||||||
* description="建立新的管理員帳號",
|
* description="建立新的管理員帳號",
|
||||||
* operationId="registerAdmin",
|
* operationId="registerAdmin",
|
||||||
@@ -877,7 +881,7 @@ class AuthApiDoc
|
|||||||
* 管理員登入
|
* 管理員登入
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/login/admin",
|
* path="/admin/login",
|
||||||
* summary="管理員登入",
|
* summary="管理員登入",
|
||||||
* description="管理員帳號登入系統",
|
* description="管理員帳號登入系統",
|
||||||
* operationId="loginAdmin",
|
* operationId="loginAdmin",
|
||||||
@@ -958,7 +962,7 @@ class AuthApiDoc
|
|||||||
* 管理員登出
|
* 管理員登出
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/logout/admin",
|
* path="/admin/logout",
|
||||||
* summary="管理員登出",
|
* summary="管理員登出",
|
||||||
* description="管理員登出系統並撤銷當前令牌",
|
* description="管理員登出系統並撤銷當前令牌",
|
||||||
* operationId="logoutAdmin",
|
* operationId="logoutAdmin",
|
||||||
@@ -992,7 +996,7 @@ class AuthApiDoc
|
|||||||
* 取得管理員個人資料
|
* 取得管理員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/profile/admin",
|
* path="/admin/profile",
|
||||||
* summary="取得管理員個人資料",
|
* summary="取得管理員個人資料",
|
||||||
* description="取得當前登入管理員的個人資料",
|
* description="取得當前登入管理員的個人資料",
|
||||||
* operationId="adminProfile",
|
* operationId="adminProfile",
|
||||||
@@ -1030,7 +1034,7 @@ class AuthApiDoc
|
|||||||
* 更新管理員個人資料
|
* 更新管理員個人資料
|
||||||
*
|
*
|
||||||
* @OA\Put(
|
* @OA\Put(
|
||||||
* path="/profile/admin",
|
* path="/admin/profile",
|
||||||
* summary="更新管理員個人資料",
|
* summary="更新管理員個人資料",
|
||||||
* description="更新當前登入管理員的個人資料",
|
* description="更新當前登入管理員的個人資料",
|
||||||
* operationId="updateAdminProfile",
|
* operationId="updateAdminProfile",
|
||||||
@@ -1087,8 +1091,8 @@ class AuthApiDoc
|
|||||||
/**
|
/**
|
||||||
* 修改管理員密碼
|
* 修改管理員密碼
|
||||||
*
|
*
|
||||||
* @OA\Post(
|
* @OA\Put(
|
||||||
* path="/password/admin",
|
* path="/admin/change-password",
|
||||||
* summary="修改管理員密碼",
|
* summary="修改管理員密碼",
|
||||||
* description="修改當前登入管理員的密碼",
|
* description="修改當前登入管理員的密碼",
|
||||||
* operationId="changeAdminPassword",
|
* operationId="changeAdminPassword",
|
||||||
@@ -1143,4 +1147,71 @@ class AuthApiDoc
|
|||||||
public function changeAdminPassword()
|
public function changeAdminPassword()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用登出
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/logout",
|
||||||
|
* summary="通用登出",
|
||||||
|
* description="撤銷當前 Bearer token,適用所有角色",
|
||||||
|
* operationId="logout",
|
||||||
|
* tags={"認證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="登出成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="登出成功")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function logout()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得當前使用者
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/user",
|
||||||
|
* summary="取得當前使用者",
|
||||||
|
* description="回傳當前 Bearer token 所屬的使用者資訊",
|
||||||
|
* operationId="currentUser",
|
||||||
|
* tags={"認證"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="name", type="string", example="王小明"),
|
||||||
|
* @OA\Property(property="email", type="string", example="user@example.com"),
|
||||||
|
* @OA\Property(property="role", type="string", enum={"member","provider","admin"}, example="member"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="未認證",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function currentUser()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Google OAuth",
|
||||||
|
* description="Google OAuth 2.0 社群登入"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class AuthSupplementDoc
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 取得 Google OAuth 重定向 URL
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/auth/google/redirect",
|
||||||
|
* summary="取得 Google OAuth 重定向 URL",
|
||||||
|
* description="回傳 Google OAuth 授權頁面的 redirect_url,前端應將使用者導向此 URL 以啟動 OAuth 流程",
|
||||||
|
* operationId="googleRedirect",
|
||||||
|
* tags={"Google OAuth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得 redirect_url 成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="redirect_url", type="string", format="uri", example="https://accounts.google.com/o/oauth2/auth?client_id=...")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function googleRedirect()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google OAuth 回調
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/auth/google/callback",
|
||||||
|
* summary="Google OAuth 回調",
|
||||||
|
* description="Google OAuth 授權完成後的回調端點,通常由瀏覽器自動呼叫。成功後回傳 Bearer token 與使用者資訊",
|
||||||
|
* operationId="googleCallback",
|
||||||
|
* tags={"Google OAuth"},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="code",
|
||||||
|
* in="query",
|
||||||
|
* required=true,
|
||||||
|
* description="Google 授權碼",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="state",
|
||||||
|
* in="query",
|
||||||
|
* required=false,
|
||||||
|
* description="OAuth state 參數",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="OAuth 登入成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="登入成功"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
|
||||||
|
* @OA\Property(property="token_type", type="string", example="Bearer"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="user",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="name", type="string", example="王小明"),
|
||||||
|
* @OA\Property(property="email", type="string", example="user@gmail.com"),
|
||||||
|
* @OA\Property(property="role", type="string", example="member"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=422,
|
||||||
|
* description="OAuth 驗證失敗",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="message", type="string", example="OAuth 驗證失敗")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function googleCallback()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="會員預約",
|
||||||
|
* description="會員的預約管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="會員評價",
|
||||||
|
* description="會員的評價管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="通知",
|
||||||
|
* description="站內通知管理(Member / Provider 共用)"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class MemberApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Member Bookings
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立預約
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/member/bookings",
|
||||||
|
* summary="建立預約",
|
||||||
|
* description="會員建立新的課程預約",
|
||||||
|
* operationId="createBooking",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"offer_id","schedule_id","participants"},
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
|
||||||
|
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
|
||||||
|
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
|
||||||
|
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="預約建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約成功"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=422,
|
||||||
|
* description="驗證失敗或時段已滿",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="無權限(非 member 角色)",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得我的預約列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/member/bookings",
|
||||||
|
* summary="取得我的預約列表",
|
||||||
|
* description="分頁回傳當前會員的所有預約",
|
||||||
|
* operationId="listMemberBookings",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listMemberBookings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一預約
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/member/bookings/{id}",
|
||||||
|
* summary="取得單一預約",
|
||||||
|
* operationId="getMemberBooking",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getMemberBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消預約
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/member/bookings/{id}",
|
||||||
|
* summary="取消預約",
|
||||||
|
* description="會員取消自己的預約",
|
||||||
|
* operationId="cancelBooking",
|
||||||
|
* tags={"會員預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取消成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已取消")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function cancelBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Member Reviews
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立評價
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/member/reviews",
|
||||||
|
* summary="建立評價",
|
||||||
|
* description="會員對已完成的預約課程提交評價",
|
||||||
|
* operationId="createReview",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"booking_id","rating"},
|
||||||
|
* @OA\Property(property="booking_id", type="integer", example=5),
|
||||||
|
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
|
||||||
|
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
|
||||||
|
* @OA\Property(property="is_anonymous", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="評價建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="評價已提交"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新評價
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/member/reviews/{id}",
|
||||||
|
* summary="更新評價",
|
||||||
|
* operationId="updateReview",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
|
||||||
|
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
|
||||||
|
* @OA\Property(property="is_anonymous", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="更新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function updateReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除評價
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/member/reviews/{id}",
|
||||||
|
* summary="刪除評價",
|
||||||
|
* operationId="deleteReview",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteReview()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記評價為有幫助
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/reviews/{id}/helpful",
|
||||||
|
* summary="標記評價為有幫助",
|
||||||
|
* description="切換投票狀態(已投票則撤回)",
|
||||||
|
* operationId="markReviewHelpful",
|
||||||
|
* tags={"會員評價"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="操作成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="helpful_count", type="integer", example=4),
|
||||||
|
* @OA\Property(property="has_voted", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markReviewHelpful()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Notifications (Member + Provider 共用)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得通知列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/notifications",
|
||||||
|
* summary="取得通知列表",
|
||||||
|
* description="分頁回傳當前使用者的所有通知(Member / Provider 共用)",
|
||||||
|
* operationId="listNotifications",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="type", type="string", example="booking_confirmed"),
|
||||||
|
* @OA\Property(property="title", type="string", example="預約已確認"),
|
||||||
|
* @OA\Property(property="message", type="string", example="您的預約已由教練確認"),
|
||||||
|
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listNotifications()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得未讀通知數量
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/notifications/unread-count",
|
||||||
|
* summary="取得未讀通知數量",
|
||||||
|
* operationId="getUnreadNotificationCount",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="unread_count", type="integer", example=3)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getUnreadNotificationCount()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記單一通知為已讀
|
||||||
|
*
|
||||||
|
* @OA\Patch(
|
||||||
|
* path="/notifications/{id}/read",
|
||||||
|
* summary="標記單一通知為已讀",
|
||||||
|
* operationId="markNotificationRead",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markNotificationRead()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部通知標記為已讀
|
||||||
|
*
|
||||||
|
* @OA\Patch(
|
||||||
|
* path="/notifications/read-all",
|
||||||
|
* summary="全部通知標記為已讀",
|
||||||
|
* operationId="markAllNotificationsRead",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function markAllNotificationsRead()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除通知
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/notifications/{id}",
|
||||||
|
* summary="刪除通知",
|
||||||
|
* operationId="deleteNotification",
|
||||||
|
* tags={"通知"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="通知已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteNotification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練課程",
|
||||||
|
* description="服務提供者的課程管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練圖片",
|
||||||
|
* description="服務提供者的課程圖片管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練時段",
|
||||||
|
* description="服務提供者的課程時段管理"
|
||||||
|
* )
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="教練預約",
|
||||||
|
* description="服務提供者的預約管理"
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class ProviderApiDoc
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Offers CRUD
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得自己的課程列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/offers",
|
||||||
|
* summary="取得自己的課程列表",
|
||||||
|
* description="回傳當前服務提供者的所有課程(分頁)",
|
||||||
|
* operationId="listProviderOffers",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listProviderOffers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立課程
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/offers",
|
||||||
|
* summary="建立課程",
|
||||||
|
* description="服務提供者建立新的潛水課程",
|
||||||
|
* operationId="createProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"title","description","region","price","max_participants"},
|
||||||
|
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
|
||||||
|
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
|
||||||
|
* @OA\Property(property="region", type="string", example="墾丁"),
|
||||||
|
* @OA\Property(property="price", type="number", format="float", example=8500),
|
||||||
|
* @OA\Property(property="max_participants", type="integer", example=6),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="課程建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="課程建立成功"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一課程(Provider 視角)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/offers/{id}",
|
||||||
|
* summary="取得單一課程(Provider 視角)",
|
||||||
|
* description="取得自己的指定課程,非本人課程回傳 403",
|
||||||
|
* operationId="getProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新課程
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/offers/{id}",
|
||||||
|
* summary="更新課程",
|
||||||
|
* operationId="updateProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
|
||||||
|
* @OA\Property(property="description", type="string", example="AOWD 課程"),
|
||||||
|
* @OA\Property(property="region", type="string", example="小琉球"),
|
||||||
|
* @OA\Property(property="price", type="number", format="float", example=12000),
|
||||||
|
* @OA\Property(property="max_participants", type="integer", example=4),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"進階","AOWD"}),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="更新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function updateProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除課程
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/offers/{id}",
|
||||||
|
* summary="刪除課程",
|
||||||
|
* operationId="deleteProviderOffer",
|
||||||
|
* tags={"教練課程"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteProviderOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Offer Images
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上傳封面圖片
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/offers/{id}/cover",
|
||||||
|
* summary="上傳封面圖片",
|
||||||
|
* description="上傳課程封面圖片(multipart/form-data)",
|
||||||
|
* operationId="uploadOfferCover",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"cover_image"},
|
||||||
|
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB)")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="上傳成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function uploadOfferCover()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除封面圖片
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/offers/{id}/cover",
|
||||||
|
* summary="刪除封面圖片",
|
||||||
|
* operationId="deleteOfferCover",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteOfferCover()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上傳相簿圖片
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/offers/{id}/images",
|
||||||
|
* summary="上傳相簿圖片",
|
||||||
|
* description="新增課程相簿圖片(multipart/form-data,可多張)",
|
||||||
|
* operationId="uploadOfferImages",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"images[]"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="images[]",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(type="string", format="binary"),
|
||||||
|
* description="相簿圖片(每張最大 5MB)"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="上傳成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function uploadOfferImages()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除相簿圖片
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/images/{id}",
|
||||||
|
* summary="刪除相簿圖片",
|
||||||
|
* operationId="deleteOfferImage",
|
||||||
|
* tags={"教練圖片"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="圖片已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteOfferImage()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Schedules
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得時段列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/schedules",
|
||||||
|
* summary="取得時段列表",
|
||||||
|
* description="回傳服務提供者的所有時段,可依 offer_id 篩選",
|
||||||
|
* operationId="listProviderSchedules",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listProviderSchedules()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立時段
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/provider/schedules",
|
||||||
|
* summary="建立時段",
|
||||||
|
* operationId="createProviderSchedule",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"offer_id","start_date","end_date","available_slots"},
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
|
||||||
|
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
|
||||||
|
* @OA\Property(property="available_slots", type="integer", example=4)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="時段建立成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createProviderSchedule()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新時段
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/schedules/{id}",
|
||||||
|
* summary="更新時段",
|
||||||
|
* operationId="updateProviderSchedule",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
|
||||||
|
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
|
||||||
|
* @OA\Property(property="available_slots", type="integer", example=6),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="更新成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function updateProviderSchedule()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除時段
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/provider/schedules/{id}",
|
||||||
|
* summary="刪除時段",
|
||||||
|
* operationId="deleteProviderSchedule",
|
||||||
|
* tags={"教練時段"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="刪除成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="時段已刪除")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function deleteProviderSchedule()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider Bookings Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得收到的預約列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/provider/bookings",
|
||||||
|
* summary="取得收到的預約列表",
|
||||||
|
* description="分頁回傳服務提供者收到的所有預約",
|
||||||
|
* operationId="listProviderBookings",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listProviderBookings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 確認預約
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/confirm",
|
||||||
|
* summary="確認預約",
|
||||||
|
* operationId="confirmBooking",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="確認成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已確認"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function confirmBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒絕預約
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/reject",
|
||||||
|
* summary="拒絕預約",
|
||||||
|
* operationId="rejectBooking",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="拒絕成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已拒絕"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function rejectBooking()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 標記預約完成
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/complete",
|
||||||
|
* summary="標記預約完成",
|
||||||
|
* operationId="completeBookingByProvider",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="標記成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function completeBookingByProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消預約(Provider)
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/provider/bookings/{id}/cancel",
|
||||||
|
* summary="取消預約(Provider)",
|
||||||
|
* operationId="cancelBookingByProvider",
|
||||||
|
* tags={"教練預約"},
|
||||||
|
* security={{"bearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取消成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="預約已取消"),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function cancelBookingByProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Docs;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="公開課程",
|
||||||
|
* description="無需認證的公開潛水課程查詢端點"
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* 共用 Schema 定義
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="PaginationMeta",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="current_page", type="integer", example=1),
|
||||||
|
* @OA\Property(property="last_page", type="integer", example=5),
|
||||||
|
* @OA\Property(property="per_page", type="integer", example=15),
|
||||||
|
* @OA\Property(property="total", type="integer", example=72)
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="ApiErrorResponse",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="status", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="message", type="string", example="操作失敗"),
|
||||||
|
* @OA\Property(property="errors", type="object", nullable=true)
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="DivingOffer",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="provider_id", type="integer", example=3),
|
||||||
|
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
|
||||||
|
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
|
||||||
|
* @OA\Property(property="region", type="string", example="墾丁"),
|
||||||
|
* @OA\Property(property="price", type="number", format="float", example=8500),
|
||||||
|
* @OA\Property(property="max_participants", type="integer", example=6),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"}),
|
||||||
|
* @OA\Property(property="cover_image_url", type="string", nullable=true, example="https://example.com/image.jpg"),
|
||||||
|
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={}),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="Review",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
|
||||||
|
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
|
||||||
|
* @OA\Property(property="is_anonymous", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="helpful_count", type="integer", example=3),
|
||||||
|
* @OA\Property(property="has_voted", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="CourseSchedule",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
|
||||||
|
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
|
||||||
|
* @OA\Property(property="available_slots", type="integer", example=4),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="Booking",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="member_id", type="integer", example=5),
|
||||||
|
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="schedule_id", type="integer", example=2),
|
||||||
|
* @OA\Property(property="status", type="string", enum={"pending","confirmed","rejected","completed","cancelled"}, example="pending"),
|
||||||
|
* @OA\Property(property="participants", type="integer", example=2),
|
||||||
|
* @OA\Property(property="note", type="string", nullable=true, example=""),
|
||||||
|
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class PublicApiDoc
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 查詢潛水課程列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/diving-offers",
|
||||||
|
* summary="查詢潛水課程列表",
|
||||||
|
* description="支援關鍵字、地區、標籤篩選,回傳分頁結果",
|
||||||
|
* operationId="listDivingOffers",
|
||||||
|
* tags={"公開課程"},
|
||||||
|
* @OA\Parameter(name="q", in="query", description="關鍵字搜尋", @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="region", in="query", description="地區篩選", @OA\Schema(type="string", example="墾丁")),
|
||||||
|
* @OA\Parameter(name="tag", in="query", description="標籤篩選", @OA\Schema(type="string", example="OWD")),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50)", @OA\Schema(type="integer", default=15, maximum=50)),
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="查詢成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/DivingOffer")
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listDivingOffers()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得單一潛水課程
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/diving-offers/{id}",
|
||||||
|
* summary="取得單一潛水課程",
|
||||||
|
* description="回傳課程詳情,包含封面圖片與相簿",
|
||||||
|
* operationId="getDivingOffer",
|
||||||
|
* tags={"公開課程"},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="課程 ID",
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=404,
|
||||||
|
* description="課程不存在",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function getDivingOffer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得課程評價列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/diving-offers/{id}/reviews",
|
||||||
|
* summary="取得課程評價列表",
|
||||||
|
* description="回傳評分摘要、分頁評價列表與分頁 meta",
|
||||||
|
* operationId="listOfferReviews",
|
||||||
|
* tags={"公開課程"},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Parameter(name="sort", in="query", description="排序方式(newest / helpful)", @OA\Schema(type="string", enum={"newest","helpful"}, default="newest")),
|
||||||
|
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||||
|
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50)", @OA\Schema(type="integer", default=15, maximum=50)),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(
|
||||||
|
* property="summary",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="average_rating", type="number", format="float", example=4.2),
|
||||||
|
* @OA\Property(property="total_reviews", type="integer", example=24),
|
||||||
|
* @OA\Property(property="distribution", type="object",
|
||||||
|
* @OA\Property(property="1", type="integer", example=1),
|
||||||
|
* @OA\Property(property="2", type="integer", example=2),
|
||||||
|
* @OA\Property(property="3", type="integer", example=3),
|
||||||
|
* @OA\Property(property="4", type="integer", example=8),
|
||||||
|
* @OA\Property(property="5", type="integer", example=10)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="reviews",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/Review")
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=404,
|
||||||
|
* description="課程不存在",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listOfferReviews()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得課程時段列表
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/diving-offers/{id}/schedules",
|
||||||
|
* summary="取得課程時段列表",
|
||||||
|
* description="回傳指定課程的所有可用時段",
|
||||||
|
* operationId="listOfferSchedules",
|
||||||
|
* tags={"公開課程"},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="取得成功",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="boolean", example=true),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/CourseSchedule")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=404,
|
||||||
|
* description="課程不存在",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function listOfferSchedules()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageRead implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $bookingId,
|
||||||
|
public string $readerType,
|
||||||
|
public int $lastReadMessageId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'MessageRead';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PresenceChannel('booking.' . $this->bookingId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reader_type' => $this->readerType,
|
||||||
|
'last_read_message_id' => $this->lastReadMessageId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\BookingMessage;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageSent implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public BookingMessage $message) {}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'MessageSent';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PresenceChannel('booking.' . $this->message->booking_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->message->id,
|
||||||
|
'sender_id' => $this->message->sender_id,
|
||||||
|
'sender_type' => $this->message->sender_type,
|
||||||
|
'type' => $this->message->type,
|
||||||
|
'content' => $this->message->content,
|
||||||
|
'created_at' => $this->message->created_at->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class NotificationCreated implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets;
|
||||||
|
|
||||||
|
public function __construct(public readonly int $userId) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
// 使用 Laravel 內建的 User private channel(channels.php 已有 auth)
|
||||||
|
return [new PrivateChannel("App.Models.User.{$this->userId}")];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'notification.created';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不需要 payload,前端收到後直接呼叫 fetchUnreadCount()
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,19 @@ use App\Models\User;
|
|||||||
use App\Models\AdminProfile;
|
use App\Models\AdminProfile;
|
||||||
use App\Models\ProviderProfile;
|
use App\Models\ProviderProfile;
|
||||||
use App\Models\MemberProfile;
|
use App\Models\MemberProfile;
|
||||||
|
use App\Traits\NormalizesEmail;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
// 科定規範角色
|
use NormalizesEmail;
|
||||||
|
|
||||||
private const ROLE_MEMBER = 'member';
|
private const ROLE_MEMBER = 'member';
|
||||||
private const ROLE_PROVIDER = 'provider';
|
private const ROLE_PROVIDER = 'provider';
|
||||||
private const ROLE_ADMIN = 'admin';
|
private const ROLE_ADMIN = 'admin';
|
||||||
@@ -131,6 +135,50 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAccountLocked(string $role, string $email): bool
|
||||||
|
{
|
||||||
|
$count = Cache::get("login_failures:{$role}:{$email}", 0);
|
||||||
|
return $count >= config('auth_lockout.max_attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordLoginFailure(string $role, string $email): int
|
||||||
|
{
|
||||||
|
$ttl = config('auth_lockout.decay_minutes') * 60;
|
||||||
|
$countKey = "login_failures:{$role}:{$email}";
|
||||||
|
$expiresKey = "login_expires_at:{$role}:{$email}";
|
||||||
|
|
||||||
|
if (!Cache::has($countKey)) {
|
||||||
|
Cache::put($countKey, 1, $ttl);
|
||||||
|
Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cache::increment($countKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearLoginFailures(string $role, string $email): void
|
||||||
|
{
|
||||||
|
Cache::forget("login_failures:{$role}:{$email}");
|
||||||
|
Cache::forget("login_expires_at:{$role}:{$email}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$decayMinutes = config('auth_lockout.decay_minutes');
|
||||||
|
$lockedUntil = Cache::get("login_expires_at:{$role}:{$email}");
|
||||||
|
|
||||||
|
if (!$lockedUntil) {
|
||||||
|
Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}");
|
||||||
|
$lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試",
|
||||||
|
'locked_until' => $lockedUntil,
|
||||||
|
], 423);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 標準化的登出方法
|
* 標準化的登出方法
|
||||||
*/
|
*/
|
||||||
@@ -268,53 +316,60 @@ class AuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function loginMember(Request $request)
|
public function loginMember(Request $request)
|
||||||
{
|
{
|
||||||
// 驗證請求數據
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'email' => 'required|string|email',
|
'email' => 'required|string|email',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '驗證失敗',
|
'message' => '驗證失敗',
|
||||||
'errors' => $validator->errors()
|
'errors' => $validator->errors()
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否存在
|
$email = $this->normalizeEmail($request->email);
|
||||||
$user = User::where('email', $request->email)
|
|
||||||
->where('role', 'member') // 只驗證會員
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// 檢查密碼
|
// 1st: 帳號鎖定檢查
|
||||||
|
if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2nd: 帳號存在確認
|
||||||
|
$user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first();
|
||||||
|
|
||||||
|
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||||
|
if ($user) {
|
||||||
|
$count = $this->recordLoginFailure(self::ROLE_MEMBER, $email);
|
||||||
|
if ($count >= config('auth_lockout.max_attempts')) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||||
|
}
|
||||||
|
}
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '電子郵件或密碼錯誤'
|
'message' => '電子郵件或密碼錯誤'
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否啟用
|
|
||||||
if (!$user->is_active) {
|
if (!$user->is_active) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '帳號已被停用'
|
'message' => '帳號已被停用'
|
||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 創建 API token
|
$this->clearLoginFailures(self::ROLE_MEMBER, $email);
|
||||||
$token = $user->createToken('auth_token')->plainTextToken;
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
// 加載會員資料
|
|
||||||
$user->load('memberProfile');
|
$user->load('memberProfile');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => true,
|
'status' => true,
|
||||||
'message' => '登入成功',
|
'message' => '登入成功',
|
||||||
'data' => [
|
'data' => [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'token_type' => 'Bearer',
|
'token_type' => 'Bearer',
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
@@ -531,53 +586,60 @@ class AuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function loginProvider(Request $request)
|
public function loginProvider(Request $request)
|
||||||
{
|
{
|
||||||
// 驗證請求數據
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'email' => 'required|string|email',
|
'email' => 'required|string|email',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '驗證失敗',
|
'message' => '驗證失敗',
|
||||||
'errors' => $validator->errors()
|
'errors' => $validator->errors()
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否存在
|
$email = $this->normalizeEmail($request->email);
|
||||||
$user = User::where('email', $request->email)
|
|
||||||
->where('role', 'provider') // 只驗證服務提供者
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// 檢查密碼
|
// 1st: 帳號鎖定檢查
|
||||||
|
if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2nd: 帳號存在確認
|
||||||
|
$user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first();
|
||||||
|
|
||||||
|
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||||
|
if ($user) {
|
||||||
|
$count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email);
|
||||||
|
if ($count >= config('auth_lockout.max_attempts')) {
|
||||||
|
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||||
|
}
|
||||||
|
}
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '電子郵件或密碼錯誤'
|
'message' => '電子郵件或密碼錯誤'
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 檢查用戶是否啟用
|
|
||||||
if (!$user->is_active) {
|
if (!$user->is_active) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
'message' => '帳號已被停用'
|
'message' => '帳號已被停用'
|
||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 創建 API token
|
$this->clearLoginFailures(self::ROLE_PROVIDER, $email);
|
||||||
$token = $user->createToken('auth_token')->plainTextToken;
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
// 加載服務提供者資料
|
|
||||||
$user->load('providerProfile');
|
$user->load('providerProfile');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => true,
|
'status' => true,
|
||||||
'message' => '登入成功',
|
'message' => '登入成功',
|
||||||
'data' => [
|
'data' => [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'token_type' => 'Bearer',
|
'token_type' => 'Bearer',
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
@@ -1051,6 +1113,63 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 會員 Token Refresh
|
||||||
|
*/
|
||||||
|
public function refreshMember(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->role !== self::ROLE_MEMBER) {
|
||||||
|
return $this->getUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服務提供者 Token Refresh
|
||||||
|
*/
|
||||||
|
public function refreshProvider(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->role !== self::ROLE_PROVIDER) {
|
||||||
|
return $this->getUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理員 Token Refresh
|
||||||
|
*/
|
||||||
|
public function refreshAdmin(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user->role !== self::ROLE_ADMIN) {
|
||||||
|
return $this->getUnauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
$token = $user->createToken('auth_token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查詢會員資料
|
* 查詢會員資料
|
||||||
* 只有管理員可以使用這個方法
|
* 只有管理員可以使用這個方法
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Events\MessageRead;
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Events\NotificationCreated;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\BookingMessage;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\NewBookingMessageNotification;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
|
||||||
|
class BookingMessageController extends Controller
|
||||||
|
{
|
||||||
|
private function authorizeParticipant(Request $request, Booking $booking): bool
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$booking->loadMissing('schedule');
|
||||||
|
|
||||||
|
if ($user->role === 'member') {
|
||||||
|
return $booking->member_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->role === 'provider') {
|
||||||
|
return $booking->schedule->provider_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unreadCounts(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||||
|
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||||||
|
|
||||||
|
if ($senderType === 'member') {
|
||||||
|
$bookingIds = Booking::where('member_id', $user->id)
|
||||||
|
->whereIn('status', ['confirmed', 'completed'])
|
||||||
|
->pluck('id');
|
||||||
|
} else {
|
||||||
|
$bookingIds = Booking::whereHas('schedule', fn($q) => $q->where('provider_id', $user->id))
|
||||||
|
->whereIn('status', ['confirmed', 'completed'])
|
||||||
|
->pluck('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次 query 取得所有 booking 的未讀數(只計對方發的、且 read_at 為 NULL)
|
||||||
|
$counts = BookingMessage::whereIn('booking_id', $bookingIds)
|
||||||
|
->where('sender_type', $otherType)
|
||||||
|
->whereNull('read_at')
|
||||||
|
->selectRaw('booking_id, COUNT(*) as count')
|
||||||
|
->groupBy('booking_id')
|
||||||
|
->pluck('count', 'booking_id');
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $counts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, Booking $booking): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->authorizeParticipant($request, $booking)) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $booking->status->value;
|
||||||
|
if (!in_array($status, ['confirmed', 'completed'])) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $booking->messages()->orderBy('created_at')->get();
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $messages]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Booking $booking): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->authorizeParticipant($request, $booking)) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($booking->status->value !== 'confirmed') {
|
||||||
|
return response()->json(['status' => false, 'message' => '訊息功能僅在預約確認期間開放'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate(['type' => 'required|in:text,image']);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||||
|
|
||||||
|
if ($request->input('type') === 'text') {
|
||||||
|
$request->validate(['content' => 'required|string|max:5000']);
|
||||||
|
|
||||||
|
$message = BookingMessage::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'sender_id' => $user->id,
|
||||||
|
'sender_type' => $senderType,
|
||||||
|
'type' => 'text',
|
||||||
|
'content' => $request->input('content'),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$request->validate([
|
||||||
|
'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$manager = new ImageManager(new Driver());
|
||||||
|
$image = $manager->read($request->file('file'));
|
||||||
|
|
||||||
|
if ($image->width() > 2048 || $image->height() > 2048) {
|
||||||
|
$image->scaleDown(width: 2048, height: 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = Str::uuid();
|
||||||
|
$filename = "booking-images/{$uuid}.jpg";
|
||||||
|
$encoded = $image->toJpeg(quality: 85);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($filename, $encoded);
|
||||||
|
$url = Storage::disk('public')->url($filename);
|
||||||
|
|
||||||
|
$message = BookingMessage::create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'sender_id' => $user->id,
|
||||||
|
'sender_type' => $senderType,
|
||||||
|
'type' => 'image',
|
||||||
|
'content' => $url,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(new MessageSent($message));
|
||||||
|
|
||||||
|
// 通知另一方(寫入 DB notification,讓 Bell 圖示更新)
|
||||||
|
$receiverId = $senderType === 'member'
|
||||||
|
? $booking->schedule->provider_id
|
||||||
|
: $booking->member_id;
|
||||||
|
|
||||||
|
$receiver = User::find($receiverId);
|
||||||
|
if ($receiver) {
|
||||||
|
// 同步寫進 DB(非 queue),確保下一行 broadcast 時 unread count 已是最新
|
||||||
|
$receiver->notify(new NewBookingMessageNotification($message, $booking, $user));
|
||||||
|
// 通知前端:立刻更新 bell badge,不等 polling
|
||||||
|
broadcast(new NotificationCreated($receiver->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true, 'data' => $message], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markRead(Request $request, Booking $booking): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$this->authorizeParticipant($request, $booking)) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $booking->status->value;
|
||||||
|
if (!in_array($status, ['confirmed', 'completed'])) {
|
||||||
|
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate(['last_read_message_id' => 'required|integer']);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||||
|
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||||||
|
|
||||||
|
$updated = BookingMessage::where('booking_id', $booking->id)
|
||||||
|
->where('sender_type', $otherType)
|
||||||
|
->where('id', '<=', $request->input('last_read_message_id'))
|
||||||
|
->whereNull('read_at')
|
||||||
|
->update(['read_at' => now()]);
|
||||||
|
|
||||||
|
if ($updated > 0 && $status === 'confirmed') {
|
||||||
|
broadcast(new MessageRead(
|
||||||
|
$booking->id,
|
||||||
|
$senderType,
|
||||||
|
$request->input('last_read_message_id'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,19 @@ class SocialAuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function redirectToGoogle()
|
public function redirectToGoogle()
|
||||||
{
|
{
|
||||||
return Socialite::driver('google')
|
$state = bin2hex(random_bytes(32));
|
||||||
|
session()->put('oauth_state', $state);
|
||||||
|
|
||||||
|
$redirect = Socialite::driver('google')
|
||||||
->scopes(['openid', 'profile', 'email'])
|
->scopes(['openid', 'profile', 'email'])
|
||||||
->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token
|
->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state])
|
||||||
->stateless()
|
|
||||||
->redirect();
|
->redirect();
|
||||||
|
|
||||||
|
// Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。
|
||||||
|
// 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。
|
||||||
|
session()->put('state', $state);
|
||||||
|
|
||||||
|
return $redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +38,16 @@ class SocialAuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function handleGoogleCallback(Request $request)
|
public function handleGoogleCallback(Request $request)
|
||||||
{
|
{
|
||||||
|
$requestState = $request->input('state');
|
||||||
|
$sessionState = session()->pull('oauth_state');
|
||||||
|
|
||||||
|
if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) {
|
||||||
|
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 獲取 Google 用戶資訊
|
// 獲取 Google 用戶資訊(Socialite 內建 state 驗證此時也會通過)
|
||||||
$googleUser = Socialite::driver('google')->stateless()->user();
|
$googleUser = Socialite::driver('google')->user();
|
||||||
|
|
||||||
// 查找相關的社交帳號
|
// 查找相關的社交帳號
|
||||||
$socialAccount = SocialAccount::where('provider', 'google')
|
$socialAccount = SocialAccount::where('provider', 'google')
|
||||||
@@ -106,9 +121,9 @@ class SocialAuthController extends Controller
|
|||||||
// 生成 Sanctum token
|
// 生成 Sanctum token
|
||||||
$token = $user->createToken('google-auth')->plainTextToken;
|
$token = $user->createToken('google-auth')->plainTextToken;
|
||||||
|
|
||||||
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
|
return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
|
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,9 @@ class Booking extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'member_id');
|
return $this->belongsTo(User::class, 'member_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function messages()
|
||||||
|
{
|
||||||
|
return $this->hasMany(BookingMessage::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BookingMessage extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'booking_id',
|
||||||
|
'sender_id',
|
||||||
|
'sender_type',
|
||||||
|
'type',
|
||||||
|
'content',
|
||||||
|
'read_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function booking()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Booking::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sender()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'sender_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\BookingMessage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
// 不走 Queue:通知需要在 HTTP response 前同步寫進 DB,
|
||||||
|
// 讓後續的 NotificationCreated broadcast 能立刻讓前端拉到正確的 unread count。
|
||||||
|
class NewBookingMessageNotification extends Notification
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly BookingMessage $message,
|
||||||
|
public readonly Booking $booking,
|
||||||
|
public readonly User $sender,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
// 聊天訊息通知只寫進 DB,不寄信(太頻繁)
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
// 顯示名稱:教練加前綴,學員直接用名字
|
||||||
|
$senderLabel = $this->sender->role === 'provider'
|
||||||
|
? "教練 {$this->sender->name}"
|
||||||
|
: $this->sender->name;
|
||||||
|
|
||||||
|
$preview = $this->message->type === 'image'
|
||||||
|
? '傳送了一張圖片'
|
||||||
|
: mb_strimwidth($this->message->content, 0, 50, '…');
|
||||||
|
|
||||||
|
// 依收件方角色決定跳轉路徑
|
||||||
|
$actionUrl = $notifiable->role === 'provider'
|
||||||
|
? config('app.frontend_url') . '/coach/bookings'
|
||||||
|
: config('app.frontend_url') . '/my-bookings';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'new_message',
|
||||||
|
'title' => "{$senderLabel} 傳來新訊息",
|
||||||
|
'body' => $preview,
|
||||||
|
'action_url' => $actionUrl,
|
||||||
|
'related_id' => $this->booking->id,
|
||||||
|
'related_type' => 'booking',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
trait NormalizesEmail
|
||||||
|
{
|
||||||
|
private function normalizeEmail(string $email): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($email));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
@@ -17,5 +18,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
\Sentry\Laravel\Integration::handles($exceptions);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
+4
-1
@@ -7,11 +7,14 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"darkaonline/l5-swagger": "^9.0",
|
"darkaonline/l5-swagger": "^9.0",
|
||||||
|
"intervention/image": "^3.11",
|
||||||
"laravel/framework": "^11.0",
|
"laravel/framework": "^11.0",
|
||||||
|
"laravel/reverb": "^1.10",
|
||||||
"laravel/sanctum": "^4.1",
|
"laravel/sanctum": "^4.1",
|
||||||
"laravel/socialite": "^5.20",
|
"laravel/socialite": "^5.20",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
"predis/predis": "^3.4"
|
"predis/predis": "^3.4",
|
||||||
|
"sentry/sentry-laravel": "^4.25"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+1534
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
|
||||||
|
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
|
||||||
|
];
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Broadcaster
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default broadcaster that will be used by the
|
||||||
|
| framework when an event needs to be broadcast. You may set this to
|
||||||
|
| any of the connections defined in the "connections" array below.
|
||||||
|
|
|
||||||
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Broadcast Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the broadcast connections that will be used
|
||||||
|
| to broadcast events to other systems or over WebSockets. Samples of
|
||||||
|
| each available type of connection are provided inside this array.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'driver' => 'reverb',
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pusher' => [
|
||||||
|
'driver' => 'pusher',
|
||||||
|
'key' => env('PUSHER_APP_KEY'),
|
||||||
|
'secret' => env('PUSHER_APP_SECRET'),
|
||||||
|
'app_id' => env('PUSHER_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||||
|
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||||
|
'port' => env('PUSHER_PORT', 443),
|
||||||
|
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||||
|
'encrypted' => true,
|
||||||
|
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'ably' => [
|
||||||
|
'driver' => 'ably',
|
||||||
|
'key' => env('ABLY_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'driver' => 'log',
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'null',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Reverb Server
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default server used by Reverb to handle
|
||||||
|
| incoming messages as well as broadcasting message to all your
|
||||||
|
| connected clients. At this time only "reverb" is supported.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('REVERB_SERVER', 'reverb'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Servers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define details for each of the supported Reverb servers.
|
||||||
|
| Each server has its own configuration options that are defined in
|
||||||
|
| the array below. You should ensure all the options are present.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'servers' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||||
|
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||||
|
'path' => env('REVERB_SERVER_PATH', ''),
|
||||||
|
'hostname' => env('REVERB_SERVER_HOSTNAME', null),
|
||||||
|
'options' => [
|
||||||
|
'tls' => [],
|
||||||
|
],
|
||||||
|
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||||
|
'scaling' => [
|
||||||
|
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||||
|
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||||
|
'server' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||||
|
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Applications
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define how Reverb applications are managed. If you choose
|
||||||
|
| to use the "config" provider, you may define an array of apps which
|
||||||
|
| your server will support, including their connection credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
|
||||||
|
'provider' => 'config',
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
[
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'allowed_origins' => ['*'],
|
||||||
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
|
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||||
|
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||||
|
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||||
|
'rate_limiting' => [
|
||||||
|
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||||
|
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||||
|
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||||
|
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+1
-1
@@ -47,7 +47,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'expiration' => null,
|
'expiration' => 60 * 24 * 7,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentry Laravel SDK configuration file.
|
||||||
|
*
|
||||||
|
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
||||||
|
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
|
||||||
|
|
||||||
|
// @see https://spotlightjs.com/
|
||||||
|
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
|
||||||
|
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
|
||||||
|
|
||||||
|
// The release version of your application
|
||||||
|
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
|
||||||
|
'release' => env('SENTRY_RELEASE'),
|
||||||
|
|
||||||
|
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
|
||||||
|
'environment' => env('SENTRY_ENVIRONMENT'),
|
||||||
|
|
||||||
|
// Override the organization ID used for trace continuation checks.
|
||||||
|
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
|
||||||
|
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
|
||||||
|
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
|
||||||
|
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
|
||||||
|
|
||||||
|
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
|
||||||
|
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
|
||||||
|
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
|
||||||
|
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
|
||||||
|
|
||||||
|
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
|
||||||
|
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
|
||||||
|
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
|
||||||
|
// 'ignore_exceptions' => [],
|
||||||
|
|
||||||
|
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
|
||||||
|
'ignore_transactions' => [
|
||||||
|
// Ignore Laravel's default health URL
|
||||||
|
'/up',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Breadcrumb specific configuration
|
||||||
|
'breadcrumbs' => [
|
||||||
|
// Capture Laravel logs as breadcrumbs
|
||||||
|
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
|
||||||
|
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Livewire components like routes as breadcrumbs
|
||||||
|
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL queries as breadcrumbs
|
||||||
|
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
|
||||||
|
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
|
||||||
|
|
||||||
|
// Capture queue job information as breadcrumbs
|
||||||
|
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture command information as breadcrumbs
|
||||||
|
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture HTTP client request information as breadcrumbs
|
||||||
|
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture send notifications as breadcrumbs
|
||||||
|
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Performance monitoring specific configuration
|
||||||
|
'tracing' => [
|
||||||
|
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
|
||||||
|
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture queue jobs as spans when executed on the sync driver
|
||||||
|
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL queries as spans
|
||||||
|
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture SQL query bindings (parameters) in SQL query spans
|
||||||
|
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
|
||||||
|
|
||||||
|
// Capture where the SQL query originated from on the SQL query spans
|
||||||
|
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
|
||||||
|
|
||||||
|
// Define a threshold in milliseconds for SQL queries to resolve their origin
|
||||||
|
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
|
||||||
|
|
||||||
|
// Capture views rendered as spans
|
||||||
|
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Livewire components as spans
|
||||||
|
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture HTTP client requests as spans
|
||||||
|
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Laravel cache events (hits, writes etc.) as spans
|
||||||
|
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture Redis operations as spans (this enables Redis events in Laravel)
|
||||||
|
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
|
||||||
|
|
||||||
|
// Capture where the Redis command originated from on the Redis command spans
|
||||||
|
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
|
||||||
|
|
||||||
|
// Capture send notifications as spans
|
||||||
|
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
|
||||||
|
|
||||||
|
// Enable tracing for requests without a matching route (404's)
|
||||||
|
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
|
||||||
|
|
||||||
|
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
|
||||||
|
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
|
||||||
|
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
|
||||||
|
|
||||||
|
// Enable the tracing integrations supplied by Sentry (recommended)
|
||||||
|
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -19,7 +19,7 @@ return new class extends Migration
|
|||||||
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
|
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
|
||||||
$table->string('password'); // 密碼
|
$table->string('password'); // 密碼
|
||||||
$table->string('phone')->nullable(); // 電話號碼,可為空
|
$table->string('phone')->nullable(); // 電話號碼,可為空
|
||||||
$table->enum('role', ['admin', 'coach', 'member'])->default('member'); // 角色:管理員、教練、會員
|
$table->enum('role', ['admin', 'coach', 'member', 'provider'])->default('member'); // 角色:管理員、教練、會員、服務提供者
|
||||||
$table->boolean('is_active')->default(true); // 是否啟用
|
$table->boolean('is_active')->default(true); // 是否啟用
|
||||||
$table->rememberToken(); // 記住我 token
|
$table->rememberToken(); // 記住我 token
|
||||||
$table->timestamps(); // 建立與更新時間
|
$table->timestamps(); // 建立與更新時間
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
|
if (DB::getDriverName() === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
|
if (DB::getDriverName() === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create("booking_messages", function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId("booking_id")->constrained("bookings")->cascadeOnDelete();
|
||||||
|
$table->foreignId("sender_id")->constrained("users")->cascadeOnDelete();
|
||||||
|
$table->enum("sender_type", ["member", "provider"]);
|
||||||
|
$table->enum("type", ["text", "image"]);
|
||||||
|
$table->text("content");
|
||||||
|
$table->timestamp("read_at")->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(["booking_id", "created_at"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists("booking_messages");
|
||||||
|
}
|
||||||
|
};
|
||||||
+42
-1
@@ -48,6 +48,8 @@ services:
|
|||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: cfdive-nginx
|
container_name: cfdive-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www
|
- ./:/var/www
|
||||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||||
@@ -68,10 +70,16 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
|
VITE_API_URL: ${VITE_API_URL:-https://api.hank-space.com}
|
||||||
|
VITE_REVERB_APP_KEY: ${VITE_REVERB_APP_KEY}
|
||||||
|
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||||
|
VITE_REVERB_PORT: ${VITE_REVERB_PORT:-8085}
|
||||||
|
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
|
||||||
image: cfdive-frontend
|
image: cfdive-frontend
|
||||||
container_name: cfdive-frontend
|
container_name: cfdive-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5173:80"
|
||||||
networks:
|
networks:
|
||||||
- cfdive-network
|
- cfdive-network
|
||||||
- proxy_net
|
- proxy_net
|
||||||
@@ -97,6 +105,8 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3306:3306" # 只綁 localhost,不對外
|
||||||
|
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
image: phpmyadmin/phpmyadmin
|
image: phpmyadmin/phpmyadmin
|
||||||
@@ -154,6 +164,37 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
reverb:
|
||||||
|
image: cfdive-platform
|
||||||
|
container_name: cfdive-reverb
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /var/www/
|
||||||
|
entrypoint: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8085:8080"
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www
|
||||||
|
environment:
|
||||||
|
- APP_KEY=${APP_KEY}
|
||||||
|
- REVERB_APP_ID=${REVERB_APP_ID}
|
||||||
|
- REVERB_APP_KEY=${REVERB_APP_KEY}
|
||||||
|
- REVERB_APP_SECRET=${REVERB_APP_SECRET}
|
||||||
|
- REVERB_HOST=reverb
|
||||||
|
- REVERB_PORT=8080
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- DB_CONNECTION=mysql
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
|
||||||
|
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
networks:
|
||||||
|
- cfdive-network
|
||||||
|
- proxy_net
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cfdive-network:
|
cfdive-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
+43
-102
@@ -3,120 +3,61 @@ set -e
|
|||||||
|
|
||||||
echo "=== CFDivePlatform 容器初始化開始 ==="
|
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||||
|
|
||||||
# 檢查目錄結構
|
# 確保目錄與權限(php-fpm 啟動前必須完成)
|
||||||
if [ ! -d "/var/www/storage" ]; then
|
[ ! -d "/var/www/storage" ] && mkdir -p /var/www/storage
|
||||||
echo "創建 storage 目錄..."
|
[ ! -d "/var/www/bootstrap/cache" ] && mkdir -p /var/www/bootstrap/cache
|
||||||
mkdir -p /var/www/storage
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "/var/www/bootstrap/cache" ]; then
|
|
||||||
echo "創建 bootstrap/cache 目錄..."
|
|
||||||
mkdir -p /var/www/bootstrap/cache
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 設置權限
|
|
||||||
echo "設置目錄權限..."
|
|
||||||
chown -R www-data:www-data /var/www
|
chown -R www-data:www-data /var/www
|
||||||
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||||
|
|
||||||
# 等待 MySQL 服務啟動
|
# 確保 .env 存在
|
||||||
echo "等待 MySQL 服務啟動..."
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
fi
|
||||||
|
|
||||||
# 使用更穩定的方法檢查 MySQL 連接
|
# 強制 DB_HOST=db(Docker service name,不能用 localhost)
|
||||||
MAX_TRIES=60
|
php -r "
|
||||||
COUNT=0
|
\$env = file_get_contents('/var/www/.env');
|
||||||
|
\$env = preg_replace('/^DB_HOST=.*$/m', 'DB_HOST=db', \$env);
|
||||||
|
file_put_contents('/var/www/.env', \$env);
|
||||||
|
"
|
||||||
|
|
||||||
wait_for_mysql() {
|
# Composer 依賴(vendor 不存在時才裝,通常已存在於 volume)
|
||||||
while [ $COUNT -lt $MAX_TRIES ]; do
|
if [ -f "composer.json" ] && { [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; }; then
|
||||||
if mysqladmin ping -h"db" -u"cfdiveuser" -p"**REMOVED**" --silent 2>/dev/null; then
|
composer install --no-scripts --optimize-autoloader
|
||||||
echo "✅ MySQL 服務已準備就緒"
|
fi
|
||||||
return 0
|
|
||||||
fi
|
# 背景執行:等 MySQL → migration → cache clear → storage link → swagger
|
||||||
|
# php-fpm 不等這些完成就先啟動,避免重啟時 CORS 502
|
||||||
# 備用檢查方法
|
(
|
||||||
if php -r "
|
echo "⏳ [背景] 等待 MySQL..."
|
||||||
try {
|
COUNT=0
|
||||||
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', '**REMOVED**');
|
until mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null || [ $COUNT -ge 30 ]; do
|
||||||
echo 'PHP-PDO-OK';
|
|
||||||
exit(0);
|
|
||||||
} catch(Exception \$e) {
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
" 2>/dev/null; then
|
|
||||||
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
|
|
||||||
sleep 2
|
sleep 2
|
||||||
COUNT=$((COUNT+1))
|
COUNT=$((COUNT+1))
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ $COUNT -eq $MAX_TRIES ]; then
|
echo "🗄️ [背景] 執行 migration..."
|
||||||
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
php artisan migrate --force || echo "⚠️ migration 失敗"
|
||||||
|
|
||||||
|
echo "🧹 [背景] 清除 Laravel 緩存..."
|
||||||
|
php artisan config:clear || true
|
||||||
|
php artisan cache:clear || true
|
||||||
|
php artisan route:clear || true
|
||||||
|
php artisan view:clear || true
|
||||||
|
|
||||||
|
echo "🔗 [背景] storage:link..."
|
||||||
|
php artisan storage:link --force || true
|
||||||
|
|
||||||
|
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||||
|
php artisan l5-swagger:generate || true
|
||||||
fi
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_mysql
|
echo "✅ [背景] 初始化完成"
|
||||||
|
) &
|
||||||
|
|
||||||
# 檢查並安裝 Composer 依賴
|
# 啟動 cron(Laravel Scheduler)
|
||||||
echo "📦 檢查 Composer 依賴..."
|
|
||||||
if [ -f "composer.json" ]; then
|
|
||||||
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
|
|
||||||
echo "安裝 Composer 依賴..."
|
|
||||||
composer install --no-scripts --no-autoloader --optimize-autoloader
|
|
||||||
composer dump-autoload --optimize
|
|
||||||
else
|
|
||||||
echo "✅ Composer 依賴已是最新"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 設置 Laravel 環境
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "🔧 創建 .env 檔案..."
|
|
||||||
cp .env.example .env
|
|
||||||
php artisan key:generate
|
|
||||||
else
|
|
||||||
echo "✅ .env 檔案已存在"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 更新環境變數以確保正確配置
|
|
||||||
echo "🔧 更新資料庫配置..."
|
|
||||||
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
|
|
||||||
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=**REMOVED**/g" .env
|
|
||||||
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
|
|
||||||
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
|
|
||||||
|
|
||||||
# 執行遷移(如果數據庫已準備好)
|
|
||||||
echo "🗄️ 執行數據庫遷移..."
|
|
||||||
if php artisan migrate:status 2>/dev/null; then
|
|
||||||
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
|
|
||||||
else
|
|
||||||
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 清除與優化 Laravel 緩存
|
|
||||||
echo "🧹 清除 Laravel 緩存..."
|
|
||||||
php artisan config:clear || true
|
|
||||||
php artisan cache:clear || true
|
|
||||||
php artisan route:clear || true
|
|
||||||
php artisan view:clear || true
|
|
||||||
|
|
||||||
# 生成 Swagger 文檔(如果可能)
|
|
||||||
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
|
||||||
echo "📖 生成 API 文檔..."
|
|
||||||
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ CFDivePlatform 初始化完成!"
|
|
||||||
|
|
||||||
# 建立 storage symlink
|
|
||||||
echo "🔗 建立 storage symlink..."
|
|
||||||
php artisan storage:link --force || true
|
|
||||||
|
|
||||||
# 啟動 cron daemon(Laravel Scheduler)
|
|
||||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
|
||||||
service cron start || cron || true
|
service cron start || cron || true
|
||||||
|
|
||||||
# 執行傳入的命令
|
echo "🚀 啟動 php-fpm..."
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -8,7 +8,16 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_URL=http://localhost:8080
|
ARG VITE_API_URL=http://localhost:8080
|
||||||
|
ARG VITE_REVERB_APP_KEY
|
||||||
|
ARG VITE_REVERB_HOST=localhost
|
||||||
|
ARG VITE_REVERB_PORT=8085
|
||||||
|
ARG VITE_REVERB_SCHEME=http
|
||||||
|
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY
|
||||||
|
ENV VITE_REVERB_HOST=$VITE_REVERB_HOST
|
||||||
|
ENV VITE_REVERB_PORT=$VITE_REVERB_PORT
|
||||||
|
ENV VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
Generated
+230
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -450,6 +452,12 @@
|
|||||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -890,6 +898,23 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -938,6 +963,28 @@
|
|||||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||||
|
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.20.1",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -1315,6 +1362,18 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/laravel-echo": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pusher-js": "*",
|
||||||
|
"socket.io-client": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1656,6 +1715,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -1946,6 +2011,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -2097,6 +2170,34 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2257,6 +2358,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2408,6 +2514,36 @@
|
|||||||
"version": "6.6.4",
|
"version": "6.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2664,6 +2800,12 @@
|
|||||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"@tybys/wasm-util": {
|
"@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -2979,6 +3121,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"delayed-stream": {
|
"delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -3018,6 +3169,25 @@
|
|||||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"engine.io-client": {
|
||||||
|
"version": "6.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||||
|
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.20.1",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"entities": {
|
"entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -3263,6 +3433,12 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"laravel-echo": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"lightningcss": {
|
"lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -3427,6 +3603,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||||
},
|
},
|
||||||
|
"ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"mz": {
|
"mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -3578,6 +3760,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
|
||||||
},
|
},
|
||||||
|
"pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"requires": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"queue-microtask": {
|
"queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -3675,6 +3865,28 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source-map-js": {
|
"source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3794,6 +4006,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
|
||||||
|
},
|
||||||
"update-browserslist-db": {
|
"update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -3850,6 +4067,19 @@
|
|||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ws": {
|
||||||
|
"version": "8.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
|
"xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"peer": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,11 +6,76 @@ const api = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = sessionStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let isRedirecting = false
|
||||||
|
let pendingRequests = []
|
||||||
|
|
||||||
|
function resolvePending(token) {
|
||||||
|
pendingRequests.forEach((cb) => cb(token))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPending(error) {
|
||||||
|
pendingRequests.forEach((cb) => cb(null, error))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config
|
||||||
|
const status = error.response?.status
|
||||||
|
|
||||||
|
const isAuthEndpoint =
|
||||||
|
config.url.includes('/login') ||
|
||||||
|
config.url.includes('/register') ||
|
||||||
|
config.url.includes('/refresh')
|
||||||
|
|
||||||
|
if (status !== 401 || isAuthEndpoint || config._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push((token, err) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
resolve(api(config))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
config._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/member/refresh')
|
||||||
|
const newToken = data.data.token
|
||||||
|
sessionStorage.setItem('token', newToken)
|
||||||
|
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||||
|
resolvePending(newToken)
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return api(config)
|
||||||
|
} catch {
|
||||||
|
rejectPending(error)
|
||||||
|
if (!isRedirecting) {
|
||||||
|
isRedirecting = true
|
||||||
|
sessionStorage.removeItem('token')
|
||||||
|
sessionStorage.removeItem('user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -6,11 +6,76 @@ const coachApi = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
coachApi.interceptors.request.use((config) => {
|
coachApi.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('coach_token')
|
const token = sessionStorage.getItem('coach_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let isRedirecting = false
|
||||||
|
let pendingRequests = []
|
||||||
|
|
||||||
|
function resolvePending(token) {
|
||||||
|
pendingRequests.forEach((cb) => cb(token))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPending(error) {
|
||||||
|
pendingRequests.forEach((cb) => cb(null, error))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
coachApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config
|
||||||
|
const status = error.response?.status
|
||||||
|
|
||||||
|
const isAuthEndpoint =
|
||||||
|
config.url.includes('/login') ||
|
||||||
|
config.url.includes('/register') ||
|
||||||
|
config.url.includes('/refresh')
|
||||||
|
|
||||||
|
if (status !== 401 || isAuthEndpoint || config._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push((token, err) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
resolve(coachApi(config))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
config._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await coachApi.post('/provider/refresh')
|
||||||
|
const newToken = data.data.token
|
||||||
|
sessionStorage.setItem('coach_token', newToken)
|
||||||
|
coachApi.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||||
|
resolvePending(newToken)
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return coachApi(config)
|
||||||
|
} catch {
|
||||||
|
rejectPending(error)
|
||||||
|
if (!isRedirecting) {
|
||||||
|
isRedirecting = true
|
||||||
|
sessionStorage.removeItem('coach_token')
|
||||||
|
sessionStorage.removeItem('coach_user')
|
||||||
|
window.location.href = '/coach/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default coachApi
|
export default coachApi
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ notificationApi.interceptors.request.use((config) => {
|
|||||||
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
||||||
const isCoachPage = window.location.pathname.startsWith('/coach')
|
const isCoachPage = window.location.pathname.startsWith('/coach')
|
||||||
const token = isCoachPage
|
const token = isCoachPage
|
||||||
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
|
? (sessionStorage.getItem('coach_token') || sessionStorage.getItem('token'))
|
||||||
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
|
: (sessionStorage.getItem('token') || sessionStorage.getItem('coach_token'))
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import api from '../api/axios'
|
||||||
|
import coachApi from '../api/coachAxios'
|
||||||
|
import echo from '../plugins/echo'
|
||||||
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
bookingId: { type: Number, required: true },
|
||||||
|
bookingStatus: { type: String, required: true },
|
||||||
|
currentUserType: { type: String, required: true }, // 'member' | 'provider'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['read'])
|
||||||
|
|
||||||
|
const messages = ref([])
|
||||||
|
const messageListRef = ref(null)
|
||||||
|
const textInput = ref('')
|
||||||
|
const isSending = ref(false)
|
||||||
|
const otherUserOnline = ref(false)
|
||||||
|
const channel = ref(null)
|
||||||
|
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
const isConfirmed = computed(() => props.bookingStatus === 'confirmed')
|
||||||
|
const isCompleted = computed(() => props.bookingStatus === 'completed')
|
||||||
|
const canSend = computed(() => isConfirmed.value && !isSending.value && textInput.value.trim())
|
||||||
|
const otherType = computed(() => props.currentUserType === 'member' ? 'provider' : 'member')
|
||||||
|
|
||||||
|
const axiosInstance = computed(() => props.currentUserType === 'provider' ? coachApi : api)
|
||||||
|
|
||||||
|
// 請求瀏覽器通知權限(只問一次)
|
||||||
|
async function requestBrowserNotificationPermission() {
|
||||||
|
if ('Notification' in window && Notification.permission === 'default') {
|
||||||
|
await Notification.requestPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用者不在頁面時才推瀏覽器通知
|
||||||
|
function showBrowserNotification(msg) {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||||
|
if (!document.hidden) return // 使用者正在看這個 tab,不需要
|
||||||
|
|
||||||
|
const body = msg.type === 'image' ? '傳送了一張圖片' : msg.content
|
||||||
|
new Notification('新訊息', {
|
||||||
|
body,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
tag: `booking-chat-${props.bookingId}`, // 同一個預約只顯示一則,不疊加
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messageListRef.value) {
|
||||||
|
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.value.get(`/bookings/${props.bookingId}/messages`)
|
||||||
|
messages.value = res.data.data
|
||||||
|
scrollToBottom()
|
||||||
|
await markLastRead()
|
||||||
|
// 使用者打開聊天室後已讀,立刻刷新 bell badge
|
||||||
|
notificationStore.fetchUnreadCount()
|
||||||
|
} catch (e) {
|
||||||
|
// 403 means no access, silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markLastRead() {
|
||||||
|
if (!messages.value.length) return
|
||||||
|
const lastId = messages.value[messages.value.length - 1].id
|
||||||
|
try {
|
||||||
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages/read`, {
|
||||||
|
last_read_message_id: lastId,
|
||||||
|
})
|
||||||
|
messages.value.forEach(m => {
|
||||||
|
if (m.sender_type !== props.currentUserType && !m.read_at) {
|
||||||
|
m.read_at = new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
emit('read') // 通知父層清除未讀角標
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText() {
|
||||||
|
if (!canSend.value) return
|
||||||
|
const content = textInput.value.trim()
|
||||||
|
textInput.value = ''
|
||||||
|
isSending.value = true
|
||||||
|
|
||||||
|
const tempId = `_temp_${Date.now()}`
|
||||||
|
messages.value.push({
|
||||||
|
id: tempId,
|
||||||
|
sender_id: null,
|
||||||
|
sender_type: props.currentUserType,
|
||||||
|
type: 'text',
|
||||||
|
content,
|
||||||
|
read_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, {
|
||||||
|
type: 'text',
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
textInput.value = content
|
||||||
|
messages.value = messages.value.filter(m => m.id !== tempId)
|
||||||
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendImage(event) {
|
||||||
|
if (!isConfirmed.value) return
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
event.target.value = ''
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('type', 'image')
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
isSending.value = true
|
||||||
|
try {
|
||||||
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('圖片上傳失敗', e)
|
||||||
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeChannel() {
|
||||||
|
channel.value = echo.join(`booking.${props.bookingId}`)
|
||||||
|
.here(users => {
|
||||||
|
otherUserOnline.value = users.some(u => u.user_type === otherType.value)
|
||||||
|
// Reverb 不會發 member_added,主動 whisper 告知對方自己已上線
|
||||||
|
channel.value?.whisper('presence', { user_type: props.currentUserType, online: true })
|
||||||
|
})
|
||||||
|
.joining(user => {
|
||||||
|
if (user.user_type === otherType.value) otherUserOnline.value = true
|
||||||
|
})
|
||||||
|
.leaving(user => {
|
||||||
|
if (user.user_type === otherType.value) otherUserOnline.value = false
|
||||||
|
})
|
||||||
|
.listenForWhisper('presence', (e) => {
|
||||||
|
if (e.user_type === otherType.value) otherUserOnline.value = e.online
|
||||||
|
})
|
||||||
|
.listen('.MessageSent', async (e) => {
|
||||||
|
if (e.sender_type === props.currentUserType) {
|
||||||
|
// 自己送的訊息:替換樂觀更新的暫存訊息(避免重複)
|
||||||
|
const tempIdx = messages.value.findIndex(
|
||||||
|
m => typeof m.id === 'string' && m.id.startsWith('_temp_') && m.content === e.content
|
||||||
|
)
|
||||||
|
if (tempIdx !== -1) {
|
||||||
|
messages.value[tempIdx] = { id: e.id, sender_id: e.sender_id, sender_type: e.sender_type, type: e.type, content: e.content, read_at: null, created_at: e.created_at }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.value.push({
|
||||||
|
id: e.id,
|
||||||
|
sender_id: e.sender_id,
|
||||||
|
sender_type: e.sender_type,
|
||||||
|
type: e.type,
|
||||||
|
content: e.content,
|
||||||
|
read_at: null,
|
||||||
|
created_at: e.created_at,
|
||||||
|
})
|
||||||
|
scrollToBottom()
|
||||||
|
if (e.sender_type !== props.currentUserType) {
|
||||||
|
showBrowserNotification(e)
|
||||||
|
notificationStore.fetchUnreadCount()
|
||||||
|
await markLastRead()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.listen('.MessageRead', (e) => {
|
||||||
|
if (e.reader_type !== props.currentUserType) {
|
||||||
|
messages.value.forEach(m => {
|
||||||
|
if (m.sender_type === props.currentUserType && m.id <= e.last_read_message_id) {
|
||||||
|
m.read_at = m.read_at || new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateEchoToken() 會 disconnect/connect,connection 重建後需重訂 presence channel
|
||||||
|
// Pusher.js 的重連事件走 state_change,用 previous 判斷是否為重連而非初次連線
|
||||||
|
let everConnected = false
|
||||||
|
function onConnectionStateChange({ current }) {
|
||||||
|
if (current !== 'connected') return
|
||||||
|
if (!everConnected) { everConnected = true; return }
|
||||||
|
// 重連:重訂 presence channel
|
||||||
|
if (!isConfirmed.value) return
|
||||||
|
echo.leave(`booking.${props.bookingId}`)
|
||||||
|
channel.value = null
|
||||||
|
subscribeChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await requestBrowserNotificationPermission()
|
||||||
|
await loadHistory()
|
||||||
|
if (isConfirmed.value) {
|
||||||
|
subscribeChannel()
|
||||||
|
echo.connector.pusher.connection.bind('state_change', onConnectionStateChange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
echo.connector.pusher.connection.unbind('state_change', onConnectionStateChange)
|
||||||
|
if (channel.value) {
|
||||||
|
channel.value.whisper('presence', { user_type: props.currentUserType, online: false })
|
||||||
|
echo.leave(`booking.${props.bookingId}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isConfirmed || isCompleted" class="flex flex-col h-full border rounded-lg overflow-hidden">
|
||||||
|
<!-- 頂部狀態列 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b text-sm">
|
||||||
|
<span class="font-medium text-gray-700">訊息</span>
|
||||||
|
<div v-if="isConfirmed" class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
:class="otherUserOnline ? 'bg-green-400' : 'bg-gray-300'"
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-500">{{ otherUserOnline ? '對方在線' : '對方離線' }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400">對話已封存</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 訊息列表 -->
|
||||||
|
<div ref="messageListRef" class="flex-1 overflow-y-auto p-4 space-y-3 bg-white" style="max-height: 400px">
|
||||||
|
<div v-if="messages.length === 0" class="text-center text-gray-400 text-sm py-8">
|
||||||
|
尚無訊息
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:class="msg.sender_type === currentUserType ? 'items-end' : 'items-start'"
|
||||||
|
class="flex flex-col"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="msg.sender_type === currentUserType
|
||||||
|
? 'bg-blue-500 text-white rounded-br-none'
|
||||||
|
: 'bg-gray-100 text-gray-800 rounded-bl-none'"
|
||||||
|
class="max-w-[75%] px-3 py-2 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="msg.type === 'image'"
|
||||||
|
:src="msg.content"
|
||||||
|
alt="圖片訊息"
|
||||||
|
class="max-w-full rounded-lg"
|
||||||
|
style="max-height: 200px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5 text-[10px] text-gray-400">
|
||||||
|
<span>{{ new Date(msg.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||||
|
<span v-if="msg.sender_type === currentUserType">
|
||||||
|
{{ msg.read_at ? '已讀' : '未讀' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 輸入區(僅 confirmed) -->
|
||||||
|
<div v-if="isConfirmed" class="border-t bg-white p-3">
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<label class="flex-shrink-0 cursor-pointer text-gray-400 hover:text-blue-500 transition">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="sendImage" :disabled="isSending" />
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="textInput"
|
||||||
|
rows="1"
|
||||||
|
placeholder="輸入訊息..."
|
||||||
|
class="flex-1 resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
||||||
|
style="max-height: 80px"
|
||||||
|
@keydown.enter.exact.prevent="sendText"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="sendText"
|
||||||
|
:disabled="!canSend"
|
||||||
|
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-blue-500 text-white disabled:opacity-40 transition hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 封存提示(completed) -->
|
||||||
|
<div v-if="isCompleted" class="border-t bg-gray-50 px-4 py-3 text-center text-sm text-gray-400">
|
||||||
|
課程已結束,對話已封存
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追蹤多個 booking 的未讀訊息數。
|
||||||
|
* @param {import('axios').AxiosInstance} axiosInstance - member 或 provider 的 axios
|
||||||
|
*/
|
||||||
|
export function useBookingUnreadCounts(axiosInstance) {
|
||||||
|
const counts = ref({}) // { [bookingId]: number }
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
async function fetchCounts() {
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.get('/bookings/messages/unread-counts')
|
||||||
|
counts.value = res.data.data ?? {}
|
||||||
|
} catch {
|
||||||
|
// 靜默失敗,不影響主要頁面
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 開啟聊天室後呼叫,清除該 booking 的角標 */
|
||||||
|
function clearCount(bookingId) {
|
||||||
|
counts.value = { ...counts.value, [bookingId]: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 頁面 mount 時呼叫,立即拉取一次並啟動 60s 輪詢 */
|
||||||
|
function startPolling() {
|
||||||
|
fetchCounts()
|
||||||
|
timer = setInterval(fetchCounts, 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
return { counts, fetchCounts, clearCount, startPolling, stopPolling }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import router from './router'
|
|||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useCoachAuthStore } from './stores/coachAuth'
|
import { useCoachAuthStore } from './stores/coachAuth'
|
||||||
import { useAdminAuthStore } from './stores/adminAuth'
|
import { useAdminAuthStore } from './stores/adminAuth'
|
||||||
|
import echo from './plugins/echo'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
@@ -19,4 +20,5 @@ useCoachAuthStore().init()
|
|||||||
useAdminAuthStore().init()
|
useAdminAuthStore().init()
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.config.globalProperties.$echo = echo
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Echo from 'laravel-echo'
|
||||||
|
import Pusher from 'pusher-js'
|
||||||
|
|
||||||
|
window.Pusher = Pusher
|
||||||
|
|
||||||
|
function getAuthToken() {
|
||||||
|
return sessionStorage.getItem('coach_token') || sessionStorage.getItem('token') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const echo = new Echo({
|
||||||
|
broadcaster: 'reverb',
|
||||||
|
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||||
|
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||||
|
wsPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
authEndpoint: `${import.meta.env.VITE_API_URL}/api/broadcasting/auth`,
|
||||||
|
auth: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getAuthToken()}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登入後更新 auth header 並強制重連,確保 Reverb 用新 token 授權 channel
|
||||||
|
export function updateEchoToken() {
|
||||||
|
const token = getAuthToken()
|
||||||
|
const bearer = `Bearer ${token}`
|
||||||
|
echo.options.auth.headers.Authorization = bearer
|
||||||
|
if (echo.connector?.pusher?.config?.auth?.headers) {
|
||||||
|
echo.connector.pusher.config.auth.headers.Authorization = bearer
|
||||||
|
}
|
||||||
|
echo.disconnect()
|
||||||
|
echo.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default echo
|
||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import api from '../api/axios'
|
import api from '../api/axios'
|
||||||
import { useNotificationStore } from './notifications'
|
import { useNotificationStore } from './notifications'
|
||||||
|
import { updateEchoToken } from '../plugins/echo'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
@@ -10,32 +11,39 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const saved = localStorage.getItem('token')
|
const saved = sessionStorage.getItem('token')
|
||||||
const savedUser = localStorage.getItem('user')
|
const savedUser = sessionStorage.getItem('user')
|
||||||
if (saved) {
|
if (saved) {
|
||||||
token.value = saved
|
token.value = saved
|
||||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
ns.startRealtime(user.value?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuth(userData, tokenValue) {
|
function setAuth(userData, tokenValue) {
|
||||||
user.value = userData
|
user.value = userData
|
||||||
token.value = tokenValue
|
token.value = tokenValue
|
||||||
localStorage.setItem('token', tokenValue)
|
sessionStorage.setItem('token', tokenValue)
|
||||||
localStorage.setItem('user', JSON.stringify(userData))
|
sessionStorage.setItem('user', JSON.stringify(userData))
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
updateEchoToken()
|
||||||
|
ns.startRealtime(userData.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await api.post('/member/logout')
|
await api.post('/member/logout')
|
||||||
} catch {}
|
} catch {}
|
||||||
useNotificationStore().stopPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.stopRealtime()
|
||||||
|
ns.stopPolling()
|
||||||
user.value = null
|
user.value = null
|
||||||
token.value = null
|
token.value = null
|
||||||
localStorage.removeItem('token')
|
sessionStorage.removeItem('token')
|
||||||
localStorage.removeItem('user')
|
sessionStorage.removeItem('user')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import coachApi from '../api/coachAxios'
|
import coachApi from '../api/coachAxios'
|
||||||
import { useNotificationStore } from './notifications'
|
import { useNotificationStore } from './notifications'
|
||||||
|
import { updateEchoToken } from '../plugins/echo'
|
||||||
|
|
||||||
export const useCoachAuthStore = defineStore('coachAuth', () => {
|
export const useCoachAuthStore = defineStore('coachAuth', () => {
|
||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
@@ -10,32 +11,39 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
|
|||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const savedToken = localStorage.getItem('coach_token')
|
const savedToken = sessionStorage.getItem('coach_token')
|
||||||
const savedUser = localStorage.getItem('coach_user')
|
const savedUser = sessionStorage.getItem('coach_user')
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
token.value = savedToken
|
token.value = savedToken
|
||||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
ns.startRealtime(user.value?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuth(userData, tokenValue) {
|
function setAuth(userData, tokenValue) {
|
||||||
user.value = userData
|
user.value = userData
|
||||||
token.value = tokenValue
|
token.value = tokenValue
|
||||||
localStorage.setItem('coach_token', tokenValue)
|
sessionStorage.setItem('coach_token', tokenValue)
|
||||||
localStorage.setItem('coach_user', JSON.stringify(userData))
|
sessionStorage.setItem('coach_user', JSON.stringify(userData))
|
||||||
useNotificationStore().startPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.startPolling()
|
||||||
|
updateEchoToken()
|
||||||
|
ns.startRealtime(userData.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await coachApi.post('/provider/logout')
|
await coachApi.post('/provider/logout')
|
||||||
} catch {}
|
} catch {}
|
||||||
useNotificationStore().stopPolling()
|
const ns = useNotificationStore()
|
||||||
|
ns.stopRealtime()
|
||||||
|
ns.stopPolling()
|
||||||
user.value = null
|
user.value = null
|
||||||
token.value = null
|
token.value = null
|
||||||
localStorage.removeItem('coach_token')
|
sessionStorage.removeItem('coach_token')
|
||||||
localStorage.removeItem('coach_user')
|
sessionStorage.removeItem('coach_user')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import api from '../api/notificationAxios'
|
import api from '../api/notificationAxios'
|
||||||
|
import echo from '../plugins/echo'
|
||||||
|
|
||||||
export const useNotificationStore = defineStore('notifications', () => {
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
@@ -10,6 +11,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
let intervalId = null
|
let intervalId = null
|
||||||
let currentInterval = null
|
let currentInterval = null
|
||||||
let visibilityHandler = null
|
let visibilityHandler = null
|
||||||
|
let privateChannel = null // 訂閱 private user channel 用
|
||||||
|
|
||||||
async function fetchUnreadCount() {
|
async function fetchUnreadCount() {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +25,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 401) { stopPolling(); return }
|
||||||
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
|
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +36,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
notifications.value = res.data.data
|
notifications.value = res.data.data
|
||||||
unreadCount.value = res.data.unread_count
|
unreadCount.value = res.data.unread_count
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 401) { stopPolling(); return }
|
||||||
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
|
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,31 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 訂閱使用者的 private channel,收到 notification.created 立刻更新 bell。
|
||||||
|
* 在 startPolling() 後呼叫,需要傳入 userId。
|
||||||
|
*/
|
||||||
|
let realtimeUserId = null
|
||||||
|
|
||||||
|
function startRealtime(userId) {
|
||||||
|
if (!userId) return
|
||||||
|
stopRealtime() // 防止重複訂閱
|
||||||
|
realtimeUserId = userId
|
||||||
|
privateChannel = echo
|
||||||
|
.private(`App.Models.User.${userId}`)
|
||||||
|
.listen('.notification.created', () => {
|
||||||
|
fetchUnreadCount()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRealtime() {
|
||||||
|
if (realtimeUserId) {
|
||||||
|
echo.leave(`App.Models.User.${realtimeUserId}`)
|
||||||
|
realtimeUserId = null
|
||||||
|
privateChannel = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function markRead(id) {
|
async function markRead(id) {
|
||||||
const n = notifications.value.find(n => n.id === id)
|
const n = notifications.value.find(n => n.id === id)
|
||||||
if (n && !n.read_at) {
|
if (n && !n.read_at) {
|
||||||
@@ -110,6 +139,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
unreadCount, notifications, isOpen,
|
unreadCount, notifications, isOpen,
|
||||||
fetchNotifications, fetchUnreadCount,
|
fetchNotifications, fetchUnreadCount,
|
||||||
startPolling, stopPolling,
|
startPolling, stopPolling,
|
||||||
|
startRealtime, stopRealtime,
|
||||||
markRead, markAllRead, remove,
|
markRead, markAllRead, remove,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const route = useRoute()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const token = route.query.token
|
const token = new URLSearchParams(window.location.hash.substring(1)).get('token')
|
||||||
const error = route.query.error
|
const error = route.query.error
|
||||||
|
|
||||||
if (error || !token) {
|
if (error || !token) {
|
||||||
@@ -18,7 +18,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 存 token 先,再拉 profile
|
// 存 token 先,再拉 profile
|
||||||
localStorage.setItem('token', token)
|
sessionStorage.setItem('token', token)
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/member/profile')
|
const res = await api.get('/member/profile')
|
||||||
auth.setAuth(res.data.data, token)
|
auth.setAuth(res.data.data, token)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const error = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const oauthError = route.query.error === 'oauth_failed'
|
const oauthError = route.query.error === 'oauth_failed'
|
||||||
? 'Google 登入失敗,請重試。'
|
? 'OAuth 授權失敗,請重新嘗試'
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const successMsg = route.query.registered ? '註冊成功,請登入。' : ''
|
const successMsg = route.query.registered ? '註冊成功,請登入。' : ''
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { getMyBookings, cancelBooking } from '../api/bookingApi'
|
import { getMyBookings, cancelBooking } from '../api/bookingApi'
|
||||||
|
import BookingChat from '../components/BookingChat.vue'
|
||||||
|
import { useBookingUnreadCounts } from '../composables/useBookingUnreadCounts'
|
||||||
|
import api from '../api/axios'
|
||||||
|
|
||||||
const bookings = ref([])
|
const bookings = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const expanded = ref(new Set())
|
const expanded = ref(new Set())
|
||||||
|
|
||||||
|
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(api)
|
||||||
|
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
|
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
|
||||||
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
|
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
|
||||||
@@ -26,6 +31,7 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
startPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggle(id) {
|
function toggle(id) {
|
||||||
@@ -84,6 +90,16 @@ function formatDate(dateStr) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<!-- 未讀訊息角標 -->
|
||||||
|
<span
|
||||||
|
v-if="(unreadCounts[b.id] ?? 0) > 0"
|
||||||
|
class="flex items-center gap-1 bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full leading-none"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
|
</svg>
|
||||||
|
{{ unreadCounts[b.id] }}
|
||||||
|
</span>
|
||||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||||
</span>
|
</span>
|
||||||
@@ -141,6 +157,15 @@ function formatDate(dateStr) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 即時訊息(confirmed / completed) -->
|
||||||
|
<BookingChat
|
||||||
|
v-if="b.status === 'confirmed' || b.status === 'completed'"
|
||||||
|
:bookingId="b.id"
|
||||||
|
:bookingStatus="b.status"
|
||||||
|
currentUserType="member"
|
||||||
|
@read="clearCount(b.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 操作按鈕列 -->
|
<!-- 操作按鈕列 -->
|
||||||
<div class="flex items-center justify-between pt-1">
|
<div class="flex items-center justify-between pt-1">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
|
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
|
||||||
|
import BookingChat from '../../components/BookingChat.vue'
|
||||||
|
import { useBookingUnreadCounts } from '../../composables/useBookingUnreadCounts'
|
||||||
|
import coachApi from '../../api/coachAxios'
|
||||||
|
|
||||||
const bookings = ref([])
|
const bookings = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -31,8 +34,23 @@ const groupedByOffer = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
|
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
|
||||||
|
const chatExpanded = ref(new Set())
|
||||||
|
|
||||||
onMounted(fetchBookings)
|
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(coachApi)
|
||||||
|
|
||||||
|
function toggleChat(id) {
|
||||||
|
if (chatExpanded.value.has(id)) chatExpanded.value.delete(id)
|
||||||
|
else chatExpanded.value.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canChat(status) {
|
||||||
|
return status === 'confirmed' || status === 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchBookings()
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
async function fetchBookings() {
|
async function fetchBookings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -88,43 +106,68 @@ async function doAction(booking, action) {
|
|||||||
<div
|
<div
|
||||||
v-for="b in group"
|
v-for="b in group"
|
||||||
:key="b.id"
|
:key="b.id"
|
||||||
class="bg-white rounded-xl border px-5 py-4 flex items-start justify-between flex-wrap gap-3"
|
class="bg-white rounded-xl border overflow-hidden"
|
||||||
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
|
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="px-5 py-4 flex items-start justify-between flex-wrap gap-3">
|
||||||
<p class="text-sm font-medium text-gray-700">
|
<div class="min-w-0">
|
||||||
{{ b.scheduled_date }} {{ b.start_time }}
|
<p class="text-sm font-medium text-gray-700">
|
||||||
</p>
|
{{ b.scheduled_date }} {{ b.start_time }}
|
||||||
<p class="text-sm text-gray-500 mt-0.5">
|
</p>
|
||||||
{{ b.member_name }}
|
<p class="text-sm text-gray-500 mt-0.5">
|
||||||
<span class="text-gray-400">({{ b.member_email }})</span>
|
{{ b.member_name }}
|
||||||
・{{ b.participants }} 人・NT$ {{ b.total_price?.toLocaleString() }}
|
<span class="text-gray-400">({{ b.member_email }})</span>
|
||||||
</p>
|
・{{ b.participants }} 人・NT$ {{ b.total_price?.toLocaleString() }}
|
||||||
<p v-if="b.notes" class="text-xs text-gray-400 mt-1">備注:{{ b.notes }}</p>
|
</p>
|
||||||
|
<p v-if="b.notes" class="text-xs text-gray-400 mt-1">備注:{{ b.notes }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||||
|
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2 flex-wrap justify-end">
|
||||||
|
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
|
||||||
|
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
|
||||||
|
確認
|
||||||
|
</button>
|
||||||
|
<button v-if="b.status === 'pending'" @click="doAction(b, 'reject')"
|
||||||
|
class="text-xs bg-red-500 hover:bg-red-400 text-white px-3 py-1 rounded-full transition">
|
||||||
|
拒絕
|
||||||
|
</button>
|
||||||
|
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'complete')"
|
||||||
|
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-full transition">
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'cancel')"
|
||||||
|
class="text-xs text-orange-500 hover:text-orange-700 underline">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button v-if="canChat(b.status)" @click="toggleChat(b.id)"
|
||||||
|
class="relative text-xs border px-3 py-1 rounded-full transition"
|
||||||
|
:class="chatExpanded.has(b.id)
|
||||||
|
? 'border-blue-400 text-blue-600'
|
||||||
|
: 'border-gray-300 hover:border-blue-400 hover:text-blue-600 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ chatExpanded.has(b.id) ? '收起訊息' : '訊息' }}
|
||||||
|
<!-- 未讀紅點 -->
|
||||||
|
<span
|
||||||
|
v-if="(unreadCounts[b.id] ?? 0) > 0 && !chatExpanded.has(b.id)"
|
||||||
|
class="absolute -top-1 -right-1 min-w-[1rem] h-4 flex items-center justify-center bg-red-500 text-white text-[9px] font-bold rounded-full px-0.5"
|
||||||
|
>{{ unreadCounts[b.id] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-2 shrink-0">
|
<!-- 即時訊息(confirmed / completed,點擊展開) -->
|
||||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
<div v-if="canChat(b.status) && chatExpanded.has(b.id)" class="border-t border-gray-100 p-4">
|
||||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
<BookingChat
|
||||||
</span>
|
:bookingId="b.id"
|
||||||
<div class="flex gap-2">
|
:bookingStatus="b.status"
|
||||||
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
|
currentUserType="provider"
|
||||||
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
|
@read="clearCount(b.id)"
|
||||||
確認
|
/>
|
||||||
</button>
|
|
||||||
<button v-if="b.status === 'pending'" @click="doAction(b, 'reject')"
|
|
||||||
class="text-xs bg-red-500 hover:bg-red-400 text-white px-3 py-1 rounded-full transition">
|
|
||||||
拒絕
|
|
||||||
</button>
|
|
||||||
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'complete')"
|
|
||||||
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-full transition">
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'cancel')"
|
|
||||||
class="text-xs text-orange-500 hover:text-orange-700 underline">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-26
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
預約成立後,會員與教練之間沒有直接溝通管道,只能透過平台外的通訊工具協調集合地點、裝備確認等細節,造成使用流程斷裂。加入即時訊息功能可以將溝通閉環留在平台內,提升黏著度與信任感。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 新增 `booking_messages` 資料表,儲存文字與圖片訊息,與 `bookings` 一對多關聯
|
||||||
|
- 新增站內通知系統:發送訊息時同步寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only),並廣播 `NotificationCreated` event 至 `private-App.Models.User.{receiverId}` 頻道,前端即時更新 Bell Icon 未讀計數(**實作中追加,超出原始 scope**)
|
||||||
|
- 新增瀏覽器 Web Notification:分頁在背景時收到對方訊息,透過 Notifications API 推送原生通知(`tag: booking-chat-{id}` 防疊加)(**實作中追加**)
|
||||||
|
- 引入 **Laravel Reverb** 作為自架 WebSocket 伺服器(docker-compose 新增 `reverb` service)
|
||||||
|
- 每個 confirmed 預約建立一條 **Presence Channel**(`presence-booking.{id}`),同時承載訊息推播與在線狀態
|
||||||
|
- 已讀回執:對方在頻道內且讀取訊息時觸發 `MessageRead` event
|
||||||
|
- 圖片訊息:透過 HTTP POST 上傳至 Laravel Storage,WebSocket 廣播包含圖片 URL
|
||||||
|
- 訊息視窗隨預約狀態開關:`confirmed` → 可讀寫;`completed` → 封存唯讀;其餘狀態不開放
|
||||||
|
- 新增 DNS subdomain `ws.hank-space.com`,透過 Nginx Proxy Manager 獨立 Proxy Host 接入(B 方案)
|
||||||
|
- 前端(Vue 3)新增訊息面板,嵌入 Member 的預約詳情頁與 Coach 的預約管理頁
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `booking-chat`: 預約確認後的即時文字與圖片訊息,含訊息歷史、在線狀態、已讀回執,課程結束後封存
|
||||||
|
- `user-presence`: 基於 Presence Channel 的每預約在線狀態追蹤(誰在線、加入/離開事件)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `booking-lifecycle`: 預約狀態機新增「訊息視窗開關」語義——`confirmed` 開啟可讀寫頻道,`completed` 切換為封存唯讀,其餘終態(rejected、expired、cancelled)無訊息頻道
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **新增套件**:`laravel/reverb`(PHP)、`intervention/image`(PHP)、`laravel-echo`、`pusher-js`(npm)
|
||||||
|
- **新增 Docker service**:`reverb`(連接 `cfdive-network` 與 `proxy_net`,port 8080 僅內網)
|
||||||
|
- **新增 API 端點**:訊息列表(`GET /api/bookings/{id}/messages`)、發送文字/圖片(`POST /api/bookings/{id}/messages`)、標記已讀(`POST /api/bookings/{id}/messages/read`)、批次未讀計數(`GET /api/bookings/messages/unread-counts`)
|
||||||
|
- **修改**:`docker-compose.yml`、`config/broadcasting.php`(connection 改為 `reverb`)
|
||||||
|
- **Infrastructure**:NPM 新增 `ws.hank-space.com` Proxy Host(WebSocket 啟用)、DNS A Record
|
||||||
|
- **不影響**:現有預約 API、評價系統、所有已完成模組
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 發送文字訊息
|
||||||
|
Member 與 Provider SHALL 能在 `confirmed` 狀態的預約中透過 `POST /api/bookings/{id}/messages` 發送文字訊息。訊息儲存後系統 SHALL 廣播 `MessageSent` event 至 `presence-booking.{id}` 頻道。
|
||||||
|
|
||||||
|
#### Scenario: 成功發送文字訊息
|
||||||
|
- **WHEN** 已認證的 Member 或 Provider 對自己參與的 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,body 為 `{ type: 'text', content: '...' }`
|
||||||
|
- **THEN** 系統建立 `BookingMessage` 記錄,回傳 201,並透過 WebSocket 廣播 `MessageSent` event(含 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`)
|
||||||
|
|
||||||
|
#### Scenario: 預約非 confirmed 狀態時不可發送
|
||||||
|
- **WHEN** 預約 status 不為 `confirmed`
|
||||||
|
- **THEN** 系統回傳 403,告知訊息功能僅在預約確認期間開放
|
||||||
|
|
||||||
|
#### Scenario: 非參與方不可發送
|
||||||
|
- **WHEN** 非該預約的 Member 或對應 Provider 嘗試發送訊息
|
||||||
|
- **THEN** 系統回傳 403 Forbidden
|
||||||
|
|
||||||
|
#### Scenario: 訊息內容不可為空
|
||||||
|
- **WHEN** `content` 為空字串或未提供
|
||||||
|
- **THEN** 系統回傳 422,`errors.content` 說明必填
|
||||||
|
|
||||||
|
### Requirement: 發送圖片訊息
|
||||||
|
Member 與 Provider SHALL 能在 `confirmed` 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 `type: image` 的 `BookingMessage` 並廣播圖片 URL。
|
||||||
|
|
||||||
|
#### Scenario: 成功上傳並發送圖片
|
||||||
|
- **WHEN** 已認證使用者對 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,`Content-Type: multipart/form-data`,`type: image`,`file` 為有效圖片(jpg/png/gif/webp,≤ 10MB)
|
||||||
|
- **THEN** 系統儲存圖片至 Storage,建立 `BookingMessage`(`content` 存圖片 URL),廣播 `MessageSent` event,回傳 201
|
||||||
|
|
||||||
|
#### Scenario: 不支援的檔案類型被拒絕
|
||||||
|
- **WHEN** 上傳的 `file` 非 jpg/png/gif/webp
|
||||||
|
- **THEN** 系統回傳 422,`errors.file` 說明僅支援圖片格式
|
||||||
|
|
||||||
|
#### Scenario: 超過大小限制被拒絕
|
||||||
|
- **WHEN** 圖片檔案大小超過 10MB
|
||||||
|
- **THEN** 系統回傳 422,`errors.file` 說明大小限制
|
||||||
|
|
||||||
|
### Requirement: 讀取訊息歷史
|
||||||
|
Member 與 Provider SHALL 能透過 `GET /api/bookings/{id}/messages` 取得該預約的完整訊息歷史,按 `created_at` 升序排列。
|
||||||
|
|
||||||
|
#### Scenario: 成功取得訊息歷史
|
||||||
|
- **WHEN** 已認證的參與方送出 `GET /api/bookings/{id}/messages`
|
||||||
|
- **THEN** 系統回傳訊息陣列,每筆包含 `id`、`sender_id`、`sender_type`、`type`、`content`、`read_at`、`created_at`
|
||||||
|
|
||||||
|
#### Scenario: 非參與方無法讀取歷史
|
||||||
|
- **WHEN** 非該預約參與方嘗試讀取訊息
|
||||||
|
- **THEN** 系統回傳 403 Forbidden
|
||||||
|
|
||||||
|
#### Scenario: 預約完成後仍可讀取歷史
|
||||||
|
- **WHEN** 預約 status 為 `completed`,參與方送出 `GET /api/bookings/{id}/messages`
|
||||||
|
- **THEN** 系統回傳完整歷史訊息(唯讀,不可繼續發送)
|
||||||
|
|
||||||
|
### Requirement: 訊息封存
|
||||||
|
預約狀態轉換為 `completed` 時,系統 SHALL 自動關閉對應 Presence Channel,訊息歷史轉為唯讀。終態(rejected、expired、member_cancelled、provider_cancelled)的預約 SHALL 不具備訊息功能。
|
||||||
|
|
||||||
|
#### Scenario: completed 後不可發送新訊息
|
||||||
|
- **WHEN** 預約 status 為 `completed`,任一方嘗試 `POST /api/bookings/{id}/messages`
|
||||||
|
- **THEN** 系統回傳 403,告知預約已結束,訊息已封存
|
||||||
|
|
||||||
|
#### Scenario: 終態預約無訊息記錄也無法發送
|
||||||
|
- **WHEN** 預約 status 為 `rejected`、`expired`、`member_cancelled` 或 `provider_cancelled`
|
||||||
|
- **THEN** `GET /api/bookings/{id}/messages` 回傳空陣列;`POST` 回傳 403
|
||||||
|
|
||||||
|
### Requirement: 標記訊息已讀
|
||||||
|
接收方讀取訊息時,系統 SHALL 更新 `read_at`,並廣播 `MessageRead` event 至頻道。
|
||||||
|
|
||||||
|
#### Scenario: 成功標記已讀
|
||||||
|
- **WHEN** 已認證使用者送出 `POST /api/bookings/{id}/messages/read`(含 `last_read_message_id`)
|
||||||
|
- **THEN** 系統將該訊息及之前所有訊息(自己未讀的)的 `read_at` 更新為當前時間,廣播 `MessageRead` event(含 `reader_type`、`last_read_message_id`)
|
||||||
|
|
||||||
|
#### Scenario: 不可標記自己的訊息
|
||||||
|
- **WHEN** 發送方嘗試標記自己發出的訊息為已讀
|
||||||
|
- **THEN** 系統忽略(不更新、不廣播),回傳 200
|
||||||
|
|
||||||
|
### Requirement: 未讀訊息計數 Endpoint
|
||||||
|
系統 SHALL 提供 `GET /api/bookings/messages/unread-counts` endpoint,一次回傳當前使用者所有相關預約的未讀訊息數,避免前端逐筆呼叫造成 N+1 請求。
|
||||||
|
|
||||||
|
#### Scenario: 取得未讀計數
|
||||||
|
- **WHEN** 已認證的 Member 或 Provider 送出 `GET /api/bookings/messages/unread-counts`
|
||||||
|
- **THEN** 系統回傳 `{ status: true, data: { "{bookingId}": count, ... } }`,僅包含未讀數 > 0 的 booking;無未讀則回傳空物件 `{}`
|
||||||
|
- **AND** 計算邏輯為:對方發送(`sender_type` 為對方角色)且 `read_at IS NULL` 的訊息數量
|
||||||
|
|
||||||
|
#### Scenario: 路由優先順序正確
|
||||||
|
- **WHEN** 路由檔案定義 `/bookings/messages/unread-counts`
|
||||||
|
- **THEN** 該路由必須在 `/bookings/{booking}/messages` 之前註冊,以防 Route Model Binding 將 `messages` 誤判為 `{booking}` 參數
|
||||||
|
|
||||||
|
### Requirement: 預約列表未讀角標
|
||||||
|
前端 SHALL 在預約列表(Member 的 `MyBookingsView`、Coach 的 `BookingManagerView`)的每筆預約卡片上,以紅色角標顯示未讀訊息數。
|
||||||
|
|
||||||
|
#### Scenario: 有未讀訊息時顯示角標
|
||||||
|
- **WHEN** `GET /api/bookings/messages/unread-counts` 回傳某 booking 的 count > 0
|
||||||
|
- **THEN** 對應預約卡片上顯示紅色角標(數字)
|
||||||
|
|
||||||
|
#### Scenario: 開啟聊天視窗後角標即時清零
|
||||||
|
- **WHEN** 使用者展開某預約的 `BookingChat` 元件,元件 mount 時呼叫 `markRead`
|
||||||
|
- **THEN** `BookingChat` emit `read` 事件,父層呼叫 `clearCount(bookingId)` 即時清零角標,無需等下一輪 60s 輪詢
|
||||||
|
|
||||||
|
### Requirement: 站內通知(Bell Icon)即時更新
|
||||||
|
當使用者收到新的聊天訊息時,系統 SHALL 即時更新其 Bell Icon 的未讀通知計數,延遲不超過廣播傳輸時間(毫秒級),不依賴輪詢。
|
||||||
|
|
||||||
|
#### Scenario: 發送訊息觸發接收方 Bell 更新
|
||||||
|
- **WHEN** 使用者 A 透過 `POST /api/bookings/{id}/messages` 成功發送訊息
|
||||||
|
- **THEN** 系統同步(非 queue)寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only)
|
||||||
|
- **AND** 系統 broadcast `NotificationCreated` event 至接收方的 `private-App.Models.User.{receiverId}` 頻道
|
||||||
|
- **AND** 接收方前端收到 `.notification.created` 事件後立即重新 fetch `/notifications/unread-count`
|
||||||
|
|
||||||
|
#### Scenario: 通知 title 包含寄件方姓名
|
||||||
|
- **WHEN** 通知寫入 DB
|
||||||
|
- **THEN** `title` 欄位格式為 `"{senderLabel} 傳來新訊息"`,其中 Provider 的 senderLabel 加上「教練 」前綴(例:「教練 王小明 傳來新訊息」),Member 直接使用姓名
|
||||||
|
|
||||||
|
#### Scenario: 不寄送 Email 通知
|
||||||
|
- **WHEN** 新訊息通知寫入
|
||||||
|
- **THEN** `via()` 僅回傳 `['database']`,不透過 mail channel 發送 Email
|
||||||
|
|
||||||
|
### Requirement: 瀏覽器 Web Notification(背景通知)
|
||||||
|
當使用者的瀏覽器分頁處於背景(`document.hidden === true`)時,系統 SHALL 觸發瀏覽器原生通知,告知有新的聊天訊息。
|
||||||
|
|
||||||
|
#### Scenario: 分頁在背景時收到訊息顯示通知
|
||||||
|
- **WHEN** `BookingChat` 收到對方的 `MessageSent` event
|
||||||
|
- **AND** `document.hidden === true`
|
||||||
|
- **AND** `Notification.permission === 'granted'`
|
||||||
|
- **THEN** 系統建立一則 `Notification`,`tag: 'booking-chat-{bookingId}'`(防止同預約疊加多則)
|
||||||
|
|
||||||
|
#### Scenario: 分頁在前景時不顯示通知
|
||||||
|
- **WHEN** `BookingChat` 收到對方的 `MessageSent` event
|
||||||
|
- **AND** `document.hidden === false`(使用者正在看畫面)
|
||||||
|
- **THEN** 系統不推送 Web Notification(避免干擾)
|
||||||
|
|
||||||
|
#### Scenario: 元件 mount 時請求通知權限
|
||||||
|
- **WHEN** `BookingChat` 元件 mount
|
||||||
|
- **THEN** 呼叫 `Notification.requestPermission()`,取得使用者授權後方可推送通知
|
||||||
|
|
||||||
|
#### Scenario: 自己的訊息不觸發通知
|
||||||
|
- **WHEN** `MessageSent` event 的 `sender_type` 與當前使用者角色相同
|
||||||
|
- **THEN** 不推送 Web Notification(只對「對方」的訊息觸發)
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 預約狀態機
|
||||||
|
系統 SHALL 維護七個合法狀態,且只允許以下轉換:
|
||||||
|
- `pending` → `confirmed`(Provider 確認)
|
||||||
|
- `pending` → `rejected`(Provider 拒絕)
|
||||||
|
- `pending` → `member_cancelled`(Member 取消)
|
||||||
|
- `pending` → `expired`(Scheduler 超時)
|
||||||
|
- `confirmed` → `completed`(Scheduler 課程後自動)
|
||||||
|
- `confirmed` → `member_cancelled`(Member 取消)
|
||||||
|
- `confirmed` → `provider_cancelled`(Provider 取消)
|
||||||
|
|
||||||
|
各狀態對應訊息頻道語義:
|
||||||
|
- `confirmed`:`presence-booking.{id}` 頻道開放,可讀寫訊息
|
||||||
|
- `completed`:頻道關閉,訊息歷史封存唯讀
|
||||||
|
- 其餘狀態(`pending`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled`):無訊息頻道
|
||||||
|
|
||||||
|
#### Scenario: 非法狀態轉換被拒絕
|
||||||
|
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
|
||||||
|
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
|
||||||
|
|
||||||
|
#### Scenario: confirmed 轉 completed 時封存訊息頻道
|
||||||
|
- **WHEN** Scheduler 將 `confirmed` 預約轉為 `completed`
|
||||||
|
- **THEN** 對應 `presence-booking.{id}` 頻道不再授權新連線;現有訊息歷史保留,後續 POST 訊息回傳 403
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 訂閱預約 Presence Channel
|
||||||
|
已認證的 Member 與 Provider SHALL 能透過 Laravel Echo 訂閱 `presence-booking.{booking_id}` 頻道,訂閱時系統 SHALL 驗證使用者確為該預約的參與方。
|
||||||
|
|
||||||
|
#### Scenario: 合法參與方成功訂閱
|
||||||
|
- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}`
|
||||||
|
- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`、`name`)
|
||||||
|
|
||||||
|
#### Scenario: 非參與方訂閱被拒絕
|
||||||
|
- **WHEN** 非該預約參與方嘗試訂閱頻道
|
||||||
|
- **THEN** `broadcasting/auth` 回傳 403,連線不建立
|
||||||
|
|
||||||
|
#### Scenario: 預約非 confirmed 狀態時頻道不授權
|
||||||
|
- **WHEN** 預約 status 不為 `confirmed`,任何使用者嘗試訂閱
|
||||||
|
- **THEN** `broadcasting/auth` 回傳 403
|
||||||
|
|
||||||
|
### Requirement: 在線狀態感知
|
||||||
|
訂閱 Presence Channel 後,系統 SHALL 回傳目前在線成員清單。任一方加入或離開時 SHALL 廣播對應事件。
|
||||||
|
|
||||||
|
#### Scenario: 取得目前在線清單
|
||||||
|
- **WHEN** 使用者成功加入 `presence-booking.{id}` 頻道
|
||||||
|
- **THEN** Echo `.here()` callback 收到目前在線的使用者清單(含 `user_id`、`user_type`、`name`)
|
||||||
|
|
||||||
|
#### Scenario: 對方加入頻道
|
||||||
|
- **WHEN** 對方(Member 或 Provider)開啟訊息視窗並成功訂閱頻道
|
||||||
|
- **THEN** 已在頻道中的使用者收到 `.joining()` event,可顯示「對方已上線」
|
||||||
|
|
||||||
|
#### Scenario: 對方離開頻道
|
||||||
|
- **WHEN** 對方關閉訊息視窗或斷線
|
||||||
|
- **THEN** 仍在頻道中的使用者收到 `.leaving()` event,可顯示「對方已離線」
|
||||||
|
|
||||||
|
### Requirement: 已讀回執顯示
|
||||||
|
前端 SHALL 依據 `MessageRead` event 更新訊息的已讀狀態,顯示「已讀」標記。
|
||||||
|
|
||||||
|
#### Scenario: 己方訊息被對方讀取後顯示已讀
|
||||||
|
- **WHEN** 頻道收到 `MessageRead` event,`reader_type` 為對方,`last_read_message_id` >= 某訊息 id
|
||||||
|
- **THEN** 前端將該訊息及之前的己方訊息顯示「已讀」標記
|
||||||
|
|
||||||
|
#### Scenario: 對方不在頻道時訊息顯示未讀
|
||||||
|
- **WHEN** 對方未訂閱頻道(離線)
|
||||||
|
- **THEN** 新發送的訊息 `read_at` 為 null,顯示「未讀」狀態
|
||||||
|
|
||||||
|
### Requirement: 未讀訊息計數
|
||||||
|
系統 SHALL 在預約列表頁顯示每個預約的未讀訊息數量角標。
|
||||||
|
|
||||||
|
#### Scenario: 有未讀訊息時顯示角標
|
||||||
|
- **WHEN** Member 或 Provider 進入預約列表,某預約有 `read_at IS NULL` 且 `sender_type` 為對方的訊息
|
||||||
|
- **THEN** 對應預約卡片顯示未讀數量角標(數字或紅點)
|
||||||
|
|
||||||
|
#### Scenario: 進入訊息視窗後角標清除
|
||||||
|
- **WHEN** 使用者進入該預約的訊息視窗,送出 `POST /api/bookings/{id}/messages/read`
|
||||||
|
- **THEN** 未讀角標消失
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
## 1. 基礎建設與套件安裝
|
||||||
|
|
||||||
|
- [x] 1.1 [後端] 安裝 `laravel/reverb`:`composer require laravel/reverb`,執行 `php artisan reverb:install` 生成 `config/reverb.php`
|
||||||
|
- [x] 1.2 [後端] 安裝 `intervention/image`:`composer require intervention/image`;用途:上傳圖片時移除 EXIF(含 GPS 座標)、強制重新編碼為 jpg/png 以防格式偽裝、限制最大尺寸(長邊 2048px)
|
||||||
|
- [x] 1.3 [前端] 安裝 `laravel-echo` 與 `pusher-js`:`npm install laravel-echo pusher-js`
|
||||||
|
- [x] 1.4 [後端] 更新 `.env`:設定 `BROADCAST_CONNECTION=reverb`、`REVERB_APP_ID=cfdive`、`REVERB_APP_KEY`(32 字元隨機)、`REVERB_APP_SECRET`(32 字元隨機)、`REVERB_HOST=reverb`(Docker service DNS,非 bind address)、`REVERB_PORT=8080`;另補 Vite 前端用的 `VITE_REVERB_APP_KEY`、`VITE_REVERB_HOST=ws.hank-space.com`、`VITE_REVERB_PORT=443`、`VITE_REVERB_SCHEME=https`
|
||||||
|
- [x] 1.5 [後端] 在 `config/broadcasting.php` 確認 `reverb` driver 設定正確(`host`、`port` 從 env 讀取)
|
||||||
|
- [x] 1.6 [後端] 在 `bootstrap/app.php` 啟用 broadcasting:確認 `->withBroadcasting()` 已加入,或在 `routes/api.php` 明確呼叫 `Broadcast::routes(['middleware' => ['auth:sanctum']])` 以確保 `/broadcasting/auth` endpoint 存在並受 Sanctum 保護
|
||||||
|
|
||||||
|
## 2. Docker 與 Infrastructure 設定
|
||||||
|
|
||||||
|
- [x] 2.1 [後端] 在 `docker-compose.yml` 新增 `reverb` service:複用 `cfdive-platform` image,`command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug`,連接 `cfdive-network` 與 `proxy_net`,`restart: unless-stopped`,`depends_on: app`
|
||||||
|
- [x] 2.2 [Infrastructure] 在 DNS 新增 A Record:`ws.hank-space.com` → VPS IP
|
||||||
|
- [x] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy Host:Domain `ws.hank-space.com`,Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證
|
||||||
|
- [x] 2.4 [後端] 執行 `docker-compose up --build reverb` 驗證 Reverb 容器啟動正常
|
||||||
|
|
||||||
|
## 3. 資料庫
|
||||||
|
|
||||||
|
- [x] 3.1 [後端] 建立 migration `create_booking_messages_table`:欄位 `id`、`booking_id`(FK)、`sender_id`(FK users)、`sender_type`(enum: member/provider)、`type`(enum: text/image)、`content`(text)、`read_at`(timestamp nullable)、`timestamps`;加 index `(booking_id, created_at)`
|
||||||
|
- [x] 3.2 [後端] 執行 `php artisan migrate` 並確認資料表建立
|
||||||
|
|
||||||
|
## 4. 後端 Model、Channel 與 Event
|
||||||
|
|
||||||
|
- [x] 4.1 [後端] 建立 `app/Models/BookingMessage.php`:定義 `$fillable`、`booking()` belongsTo、`sender()` morphTo 或一般 belongsTo(依 sender_type 切換)
|
||||||
|
- [x] 4.2 [後端] 建立 `app/Broadcasting/BookingPresenceChannel.php`:實作 `join()` 方法,eager load `schedule`;Member 驗證 `booking->member_id === $user->id`,Provider 驗證 `booking->schedule->provider_id === $user->id`;`confirmed` 以外狀態返回 `false`;授權成功回傳 `['user_id', 'user_type', 'name']`
|
||||||
|
- [x] 4.3 [後端] 在 `routes/channels.php` 註冊 `Broadcast::channel('presence-booking.{bookingId}', BookingPresenceChannel::class)`
|
||||||
|
- [x] 4.4 [後端] 建立 `app/Events/MessageSent.php`:implements `ShouldBroadcastNow`(同步廣播,MVP 不走 queue),`broadcastOn()` 返回 `new PresenceChannel("presence-booking.{$this->message->booking_id}")`,`broadcastWith()` 回傳 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`
|
||||||
|
- [x] 4.5 [後端] 建立 `app/Events/MessageRead.php`:implements `ShouldBroadcastNow`,廣播 `reader_type`、`last_read_message_id`
|
||||||
|
|
||||||
|
## 5. 後端 API
|
||||||
|
|
||||||
|
- [x] 5.1 [後端] 建立 `app/Http/Controllers/BookingMessageController.php`,方法:`index`(取得歷史)、`store`(發送文字/圖片)、`markRead`(標記已讀)
|
||||||
|
- [x] 5.2 [後端] `index` 方法:驗證使用者為參與方,回傳 `booking.messages()->orderBy('created_at')->get()`,`confirmed` 與 `completed` 均可讀取
|
||||||
|
- [x] 5.3 [後端] `store` 方法:驗證 booking status 為 `confirmed`(其他狀態回傳 403);text 訊息驗證 `content` 非空;image 訊息驗證 `file`(mimes: jpg,png,gif,webp,max: 10240KB),存至 `Storage::disk('public')`,路徑 `booking-images/{uuid}.{ext}`,`content` 存完整 URL(`Storage::url(...)`);建立 `BookingMessage`;dispatch `MessageSent` event(ShouldBroadcastNow)
|
||||||
|
- [x] 5.4 [後端] `markRead` 方法:更新「對方發送」且「id ≤ last_read_message_id」且「read_at IS NULL」的訊息之 `read_at`;booking status 為 `confirmed` 時 dispatch `MessageRead` event;`completed` 時只更新 DB,不 broadcast(頻道已關閉)
|
||||||
|
- [x] 5.5 [後端] 在 `routes/api.php` 新增路由(member 與 provider 各自的 auth middleware group):`GET /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages/read`
|
||||||
|
|
||||||
|
## 6. 前端 Echo 初始化
|
||||||
|
|
||||||
|
- [x] 6.1 [前端] 在 `frontend/src/plugins/echo.js`(新建)初始化 `Laravel Echo`:`broadcaster: 'reverb'`,`wsHost: ws.hank-space.com`,`wsPort: 443`,`wssPort: 443`,`forceTLS: true`,`enabledTransports: ['ws', 'wss']`
|
||||||
|
- [x] 6.2 [前端] 在 `frontend/src/plugins/echo.js` 支援雙 token:Member 用 `localStorage.getItem('token')`,Coach 用 `localStorage.getItem('coach_token')`,依當前角色動態設定 Authorization header;`authEndpoint` 使用完整 URL:`${import.meta.env.VITE_API_URL}/broadcasting/auth`(跨 domain 不可使用相對路徑)
|
||||||
|
- [x] 6.3 [前端] 在 `main.js` 掛載 Echo plugin
|
||||||
|
|
||||||
|
## 7. 前端訊息元件
|
||||||
|
|
||||||
|
- [x] 7.1 [前端] 建立 `frontend/src/components/BookingChat.vue`:props 接收 `bookingId`、`bookingStatus`、`currentUserType`
|
||||||
|
- [x] 7.2 [前端] `BookingChat.vue` 訂閱 `presence-booking.{bookingId}`,處理 `.here()`(顯示在線狀態)、`.joining()`、`.leaving()`
|
||||||
|
- [x] 7.3 [前端] `BookingChat.vue` 監聽 `MessageSent` event,新訊息即時 append 到訊息列表
|
||||||
|
- [x] 7.4 [前端] `BookingChat.vue` 監聽 `MessageRead` event,更新訊息「已讀」標記
|
||||||
|
- [x] 7.5 [前端] `BookingChat.vue` 實作文字輸入框與送出按鈕,call `POST /api/bookings/{id}/messages`
|
||||||
|
- [x] 7.6 [前端] `BookingChat.vue` 實作圖片上傳按鈕(`<input type="file" accept="image/*">`),以 `FormData` 送出
|
||||||
|
- [x] 7.7 [前端] `BookingChat.vue` 在 `confirmed` 狀態顯示輸入區;`completed` 狀態顯示「對話已封存」提示;其他狀態不渲染元件
|
||||||
|
- [x] 7.8 [前端] 元件 mount 時呼叫 `GET /api/bookings/{id}/messages` 載入歷史訊息,並送出 `markRead`
|
||||||
|
|
||||||
|
## 8. 前端嵌入預約詳情頁
|
||||||
|
|
||||||
|
- [x] 8.1 [前端] 在 Member 的預約詳情頁(`src/views/MyBookingsView.vue` 展開區塊)嵌入 `<BookingChat>` 元件
|
||||||
|
- [x] 8.2 [前端] 在 Coach 的預約管理頁(`src/views/coach/BookingManagerView.vue`)嵌入 `<BookingChat>` 元件,點擊「訊息」按鈕展開
|
||||||
|
- [x] 8.3 [前端] 在預約列表卡片顯示未讀訊息角標:實作 `GET /api/bookings/messages/unread-counts`(一次回傳所有 booking 的未讀數,非逐筆呼叫);建立 `useBookingUnreadCounts` composable(60s 輪詢);`BookingChat` 加 `emit('read')` 讓父層即時清零角標
|
||||||
|
|
||||||
|
## 8.5 訊息通知系統(實作中追加,超出原始 scope)
|
||||||
|
|
||||||
|
- [x] 8.5.1 [後端] 建立 `NewBookingMessageNotification`:`database` channel only(不寄信);`title` 含寄件方姓名(provider 加「教練」前綴);**不走 Queue**(同步寫入,確保廣播前 DB 已有資料)
|
||||||
|
- [x] 8.5.2 [後端] 建立 `NotificationCreated` event:`ShouldBroadcastNow`,廣播至 `private-App.Models.User.{id}`(複用 channels.php 已有的授權);`broadcastAs()` = `'notification.created'`
|
||||||
|
- [x] 8.5.3 [後端] `BookingMessageController::store()` 在 broadcast `MessageSent` 後,同步 notify receiver,再 broadcast `NotificationCreated`
|
||||||
|
- [x] 8.5.4 [前端] `notifications.js` store 新增 `startRealtime(userId)` / `stopRealtime()`:訂閱 `private-App.Models.User.{id}`,收到 `notification.created` 立刻呼叫 `fetchUnreadCount()`
|
||||||
|
- [x] 8.5.5 [前端] `auth.js` 與 `coachAuth.js` 的 `init()` / `setAuth()` / `logout()` 均呼叫 `startRealtime` / `stopRealtime`
|
||||||
|
- [x] 8.5.6 [前端] `BookingChat.vue` 新增瀏覽器通知(Web Notifications API):mount 時請求權限;收到對方 `MessageSent` 且 `document.hidden` 時推送;`tag: booking-chat-{id}` 防止同一預約疊加通知
|
||||||
|
|
||||||
|
## 9. 整合測試與手動驗證
|
||||||
|
|
||||||
|
- [x] 9.1 [整合測試] 驗證 `/broadcasting/auth` 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403
|
||||||
|
- [x] 9.2 [整合測試] 驗證 `POST /api/bookings/{id}/messages`:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422
|
||||||
|
- [x] 9.3 [整合測試] 驗證 `markRead`:己方訊息不更新;對方訊息 `read_at` 被設定;`MessageRead` 廣播觸發
|
||||||
|
- [x] 9.4 [整合測試] 驗證封存:`completed` 預約 POST 訊息回傳 403,GET 歷史正常回傳
|
||||||
|
- [x] 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發
|
||||||
|
- [x] 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片
|
||||||
|
- [x] 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-01
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
CFDivePlatform 使用 Sanctum Bearer Token 認證,token 儲存於前端 localStorage。安全評估發現三個在當前架構下可直接修復的高危漏洞,不需要切換認證機制。此 change 針對性修補,範圍刻意控制在最小改動。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Token 設定有限期,降低洩漏後的持續暴露時間
|
||||||
|
- 消除 Google OAuth redirect URL 中的 token 洩漏路徑
|
||||||
|
- 對登入端點加入基本的暴力破解防護
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不切換至 HttpOnly Cookie(需要 HTTPS、CSRF token、OAuth 重構,成本過高)
|
||||||
|
- 不修改既有 token 的儲存位置(localStorage → sessionStorage 留 P1)
|
||||||
|
- 不實作 token refresh / silent renew 機制(留 P1)
|
||||||
|
- 不清除資料庫中現存的永不過期 token(`expires_at = null` 的舊記錄接受自然淘汰;`prune-expired` 只刪 `expires_at` 已逾期的記錄,兩者不衝突)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Token 過期時間設為 7 天
|
||||||
|
|
||||||
|
**選擇**:`sanctum.expiration = 10080`(分鐘)
|
||||||
|
|
||||||
|
**原因**:7 天符合「使用者每週至少登入一次」的合理假設,過短(如 1 天)會顯著提高重新登入頻率影響 UX,過長則縮減不了多少攻擊視窗。
|
||||||
|
|
||||||
|
**替代方案考慮**:
|
||||||
|
- 1 天:洩漏視窗最小,但 UX 衝擊大
|
||||||
|
- 30 天:常見電商慣例,但本平台含金流元素不適用
|
||||||
|
- 無限:現狀,排除
|
||||||
|
|
||||||
|
**副作用**:`sanctum.expiration` 只影響新建立的 token,既有 `personal_access_tokens` 中 `expires_at = null` 的記錄不受影響。可接受——舊 token 在使用者下次登入後自然被新 token 取代。
|
||||||
|
|
||||||
|
**清理機制**:啟用 expiration 後過期 token 不會自動從資料庫刪除,需搭配 `sanctum:prune-expired --hours=168` 排程(`routes/console.php`)每日清理,避免 `personal_access_tokens` 表持續膨脹。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Google OAuth redirect 改用 URL Fragment
|
||||||
|
|
||||||
|
**選擇**:`redirect(config('app.frontend_url') . '/auth/callback#token=' . $token)`
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- URL fragment(`#` 後的部分)依 HTTP 規範不會被瀏覽器送到 server,因此**不會出現在 Nginx/server log**
|
||||||
|
- Fragment 不會包含在 `Referer` header 中
|
||||||
|
- Fragment 不會出現在瀏覽器歷史的完整記錄中(部分瀏覽器歷史仍記錄 fragment,但不在伺服器端)
|
||||||
|
- 前端改動僅一行:`route.query.token` → `new URLSearchParams(window.location.hash.substring(1)).get('token')`
|
||||||
|
|
||||||
|
**替代方案考慮**:
|
||||||
|
- **後端 POST 回 token**:OAuth provider 只能 redirect,無法直接 POST
|
||||||
|
- **server-side session 中繼**:後端 redirect 到 `/auth/callback?code=<random>` 再由前端換 token——更安全但需要 server-side session store,架構複雜度提高
|
||||||
|
- **保持 query string**:現狀,洩漏路徑未消除,排除
|
||||||
|
|
||||||
|
**選擇 fragment 而非 server-side session 的原因**:fragment 方案完全消除 server log 洩漏問題,且改動最小,對此平台規模足夠。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Rate Limiting 使用 Laravel 內建 throttle middleware
|
||||||
|
|
||||||
|
**選擇**:
|
||||||
|
- Member / Provider 登入:throttle:5,1
|
||||||
|
- Admin 登入:throttle:3,1(帳號權限較高,適當收緊)
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- Laravel 11 throttle middleware 基於 IP,不需要額外套件
|
||||||
|
- 觸發時自動回傳 HTTP 429 並帶 `Retry-After` header
|
||||||
|
- 5次/分鐘對正常使用者幾乎無感,但讓暴力破解的時間成本從秒級提高到分鐘級
|
||||||
|
|
||||||
|
**替代方案考慮**:
|
||||||
|
- `throttle:10,1`:太寬鬆,對所有角色排除
|
||||||
|
- `throttle:3,1` for member/provider:可能誤傷快速填錯密碼的正常使用者,故 member/provider 維持 5 次;admin 因帳號影響範圍大,3 次的誤傷成本可接受
|
||||||
|
- Progressive delay(每次失敗增加等待時間):需要額外實作,P0 不引入複雜度
|
||||||
|
|
||||||
|
**僅套用登入端點**:register 端點不加(新帳號建立的攻擊面不同,留 P1)
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **既有 token 不過期**:`expiration` 改動不回溯,需接受現有洩漏的舊 token 持續有效直到使用者重新登入。風險可接受,因為:(1) 此為首次設定,無歷史洩漏事件;(2) 使用者正常使用就會刷新。
|
||||||
|
- **`personal_access_tokens` 表膨脹**:啟用 expiration 後過期記錄不自動清除,需排程 `sanctum:prune-expired` → 已列入 tasks。
|
||||||
|
- **`env()` 在 config cache 下失效**:`SocialAuthController` 原本使用 `env('FRONTEND_URL')`,執行 `php artisan config:cache` 後(production 必做)`env()` 回傳 null,導致 redirect 到錯誤 URL。此 change 一併修正為 `config('app.frontend_url')`(`config/app.php` 已有此鍵)。
|
||||||
|
- **fragment 在部分瀏覽器歷史仍可見**:Chrome DevTools Application → Session History 仍記錄完整 URL 含 fragment,但這是本地攻擊面(攻擊者需存取使用者設備),遠低於 server log 洩漏的遠端攻擊面。
|
||||||
|
- **IP-based throttle 可被繞過**:攻擊者可換 IP 或使用代理。此方案擋的是基本掃描攻擊,不是針對性攻擊。P1 可加 account-level lockout。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 套用 `config/sanctum.php` 修改,重啟 PHP-FPM(或 Docker container)
|
||||||
|
2. 在 `routes/console.php` 新增 `Schedule::command('sanctum:prune-expired --hours=168')->daily();`(無需重啟,下次排程週期生效)
|
||||||
|
3. 套用 `routes/api.php` 修改,無需重啟(路由快取需清除:`php artisan route:clear`)
|
||||||
|
4. 套用後端 `SocialAuthController.php` 修改
|
||||||
|
5. 套用前端 `AuthCallbackView.vue` 修改,重新 build frontend(`npm run build`)
|
||||||
|
6. 驗證:手動測試 Google OAuth 登入流程、登入頻率限制觸發、手動執行 `php artisan sanctum:prune-expired --hours=168` 確認排程指令可正常執行
|
||||||
|
|
||||||
|
**Rollback**:各項變更相互獨立,可逐項還原。Token 過期設定還原後,新建 token 重新改為不過期(舊的已過期的 token 無法恢復)。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Coach 登入端點 `/api/provider/login` 是否也需要 rate limiting?**(已包含在此 change 中,與 member 同風險級別,統一採用 `throttle:5,1`)
|
||||||
|
- **Admin 登入是否需要更嚴格的限制?** → **決策:改為 `throttle:3,1`,在此 change 實作。** 管理員帳號一旦被暴力破解,影響範圍遠大於一般使用者(可存取所有用戶資料、審核功能),更嚴格的限制可接受,正常管理員操作不太可能在 1 分鐘內連續嘗試 3 次以上。
|
||||||
|
- **是否需要對 Google OAuth callback 加入 state parameter 驗證(CSRF for OAuth)?** → **確認留 P1。** 風險評估:`stateless()` 模式下缺少 state 驗證,理論上存在 CSRF-for-OAuth 攻擊面(攻擊者偽造 callback 讓受害者綁定攻擊者的 Google 帳號)。緩解因素:(1) Google OAuth 本身要求使用者主動授權,不能靜默觸發;(2) 攻擊者需控制受害者瀏覽器的 callback 請求,難度高;(3) 平台目前無高價值資產(金流未上線)。P1 實作時加入 `state` parameter 並在 session 中驗證。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
目前認證機制存在三個高危漏洞:Token 永不過期(一旦洩漏攻擊者永久持有存取權)、Google OAuth callback 將 Sanctum token 附在 URL query string(出現在 server log、瀏覽器歷史、Referer header),以及登入端點無頻率限制(暴力破解無防護)。三項缺陷均可在不入侵伺服器的前提下被利用。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- `config/sanctum.php`:將 `expiration` 從 `null` 改為 `10080`(7 天 × 24 小時 × 60 分鐘)
|
||||||
|
- `SocialAuthController::handleGoogleCallback()`:redirect URL 改用 `#token=` fragment 取代 `?token=` query string
|
||||||
|
- `AuthCallbackView.vue`:改從 `window.location.hash` 讀取 token,不再使用 `route.query.token`
|
||||||
|
- `routes/api.php`:對 member / provider / admin 三個登入端點套用 `throttle:5,1` middleware
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `login-rate-limiting`: 登入端點的請求頻率限制——每個 IP 每分鐘最多 5 次,超過回傳 429
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `provider-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效)
|
||||||
|
- `admin-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效)
|
||||||
|
- `member-portal-ui`: Google OAuth callback 行為變更——token 改由 URL fragment 傳遞,前端改從 hash 讀取
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **後端**:`config/sanctum.php`、`app/Http/Controllers/API/SocialAuthController.php`、`routes/api.php`
|
||||||
|
- **前端**:`frontend/src/views/AuthCallbackView.vue`
|
||||||
|
- **現有 token**:`expiration` 設定僅影響**新建立**的 token,既有的 `personal_access_tokens` 不受影響(`expires_at` 欄位為 null 的舊 token 仍長期有效),需另行清除或接受自然淘汰
|
||||||
|
- **無破壞性變更**:API 介面、response 格式、前端路由均不改變
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 管理員登入
|
||||||
|
後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。
|
||||||
|
|
||||||
|
#### Scenario: 正確帳密登入
|
||||||
|
- **WHEN** 管理員送出正確 email 與 password
|
||||||
|
- **THEN** 回傳 HTTP 200,`{ status: true, data: { user, token, token_type: "Bearer" } }`
|
||||||
|
|
||||||
|
#### Scenario: 非 admin 角色帳號嘗試登入
|
||||||
|
- **WHEN** role 非 admin 的帳號嘗試呼叫此端點
|
||||||
|
- **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }`
|
||||||
|
|
||||||
|
#### Scenario: 超過登入頻率限制
|
||||||
|
- **WHEN** 同一 IP 在 1 分鐘內送出超過 3 次登入請求
|
||||||
|
- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 管理員 Bearer Token 有效期
|
||||||
|
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token,過期後需重新登入。
|
||||||
|
|
||||||
|
#### Scenario: Token 過期後管理員請求被拒絕
|
||||||
|
- **WHEN** 管理員使用已過期(超過 7 天)的 token 送出 API 請求
|
||||||
|
- **THEN** 回傳 HTTP 401,token 視為無效
|
||||||
|
|
||||||
|
#### Scenario: 有效期內 token 正常通過認證
|
||||||
|
- **WHEN** 管理員使用未過期的 token 送出 API 請求
|
||||||
|
- **THEN** 請求正常通過認證,回傳對應資源
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 登入頻率限制
|
||||||
|
後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。
|
||||||
|
|
||||||
|
#### Scenario: Member / Provider 正常登入不受影響
|
||||||
|
- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 5 次以內的請求
|
||||||
|
- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗)
|
||||||
|
|
||||||
|
#### Scenario: Member / Provider 超過頻率限制
|
||||||
|
- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求
|
||||||
|
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
|
||||||
|
|
||||||
|
#### Scenario: Admin 超過頻率限制
|
||||||
|
- **WHEN** 同一 IP 在 1 分鐘內送出第 4 次 `/api/admin/login` 請求
|
||||||
|
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
|
||||||
|
|
||||||
|
#### Scenario: 頻率限制重置
|
||||||
|
- **WHEN** 頻率限制觸發後等待 1 分鐘
|
||||||
|
- **THEN** 該 IP 的請求計數重置,可再次正常送出登入請求
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 登入頁
|
||||||
|
前端 SHALL 提供 `/login` 頁面,供會員以 email/password 登入,以及 Google OAuth 登入入口。
|
||||||
|
|
||||||
|
#### Scenario: Email/Password 登入成功
|
||||||
|
- **WHEN** 使用者填入正確的 email 與 password 並送出
|
||||||
|
- **THEN** 呼叫 `POST /api/member/login`,儲存回傳的 token 至 localStorage,導航至 `/courses`
|
||||||
|
|
||||||
|
#### Scenario: 登入失敗
|
||||||
|
- **WHEN** 使用者填入錯誤的 email 或 password
|
||||||
|
- **THEN** 頁面顯示錯誤訊息,不跳轉
|
||||||
|
|
||||||
|
#### Scenario: Google OAuth 登入
|
||||||
|
- **WHEN** 使用者點擊「以 Google 登入」按鈕
|
||||||
|
- **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程
|
||||||
|
|
||||||
|
#### Scenario: 超過登入頻率限制
|
||||||
|
- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求
|
||||||
|
- **THEN** 前端顯示適當的錯誤訊息(對應後端回傳的 HTTP 429)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Google OAuth Callback 處理
|
||||||
|
前端 SHALL 在 `/auth/callback` 路由讀取 URL fragment(`#token=<value>`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。
|
||||||
|
|
||||||
|
#### Scenario: OAuth callback 成功取得 token
|
||||||
|
- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=<token>`
|
||||||
|
- **THEN** 前端從 `window.location.hash` 解析 token,呼叫 `/api/member/profile` 取得使用者資料,呼叫 `auth.setAuth()` 儲存認證狀態,並導航至 `/courses`
|
||||||
|
|
||||||
|
#### Scenario: OAuth callback 缺少 token
|
||||||
|
- **WHEN** redirect 到 `/auth/callback` 但 hash 中無 `token` 參數
|
||||||
|
- **THEN** 前端導航至 `/login?error=oauth_failed`,顯示錯誤訊息
|
||||||
|
|
||||||
|
#### Scenario: URL 中不留存 token
|
||||||
|
- **WHEN** callback 頁面成功處理 token 後
|
||||||
|
- **THEN** 瀏覽器網址列不顯示 token(使用 `history.replaceState` 清除 hash)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 教練帳號登入
|
||||||
|
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。回傳的 token 有效期為 7 天。
|
||||||
|
|
||||||
|
#### Scenario: 正確帳密登入成功
|
||||||
|
- **WHEN** 教練送出正確的 email 與 password
|
||||||
|
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
|
||||||
|
|
||||||
|
#### Scenario: 錯誤帳密登入失敗
|
||||||
|
- **WHEN** 教練送出錯誤的 email 或 password
|
||||||
|
- **THEN** 回傳 HTTP 401,`{ status: false, message: "帳號或密碼錯誤" }`
|
||||||
|
|
||||||
|
#### Scenario: 會員帳號無法用教練登入
|
||||||
|
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
|
||||||
|
- **THEN** 回傳 HTTP 403,`{ status: false, message: "此帳號非教練角色" }`
|
||||||
|
|
||||||
|
#### Scenario: 超過登入頻率限制
|
||||||
|
- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求
|
||||||
|
- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bearer Token 有效期
|
||||||
|
後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token,過期後使用者必須重新登入。
|
||||||
|
|
||||||
|
#### Scenario: Token 過期後請求被拒絕
|
||||||
|
- **WHEN** 教練使用已過期(超過 7 天)的 token 送出 API 請求
|
||||||
|
- **THEN** 回傳 HTTP 401,token 視為無效
|
||||||
|
|
||||||
|
#### Scenario: 有效期內 token 正常運作
|
||||||
|
- **WHEN** 教練使用未過期的 token 送出 API 請求
|
||||||
|
- **THEN** 請求正常通過認證
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## 1. Token 過期時間設定
|
||||||
|
|
||||||
|
- [x] 1.1 [後端] 修改 `config/sanctum.php`:將 `'expiration' => null` 改為 `'expiration' => 60 * 24 * 7`(10080 分鐘)
|
||||||
|
- [x] 1.2 [後端] 在 `routes/console.php` 新增排程:`Schedule::command('sanctum:prune-expired --hours=168')->daily();`,清除累積的過期 token 記錄
|
||||||
|
- [x] 1.3 [後端] 執行 `php artisan config:clear` 確認設定生效
|
||||||
|
|
||||||
|
## 2. Google OAuth Token 洩漏修復
|
||||||
|
|
||||||
|
- [x] 2.1 [後端] 修改 `app/Http/Controllers/API/SocialAuthController.php` 第 109、111 行:`env('FRONTEND_URL')` 改為 `config('app.frontend_url')`(防止 `config:cache` 後 env() 回傳 null),同時將第 109 行 `?token=` 改為 `#token=`(URL fragment)
|
||||||
|
- [x] 2.2 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:將讀取 `route.query.token` 改為讀取 `window.location.hash`(`new URLSearchParams(window.location.hash.substring(1)).get('token')`)
|
||||||
|
|
||||||
|
## 3. 登入端點 Rate Limiting
|
||||||
|
|
||||||
|
- [x] 3.1 [後端] 修改 `routes/api.php`:對 `POST /api/member/login` 套用 `throttle:5,1` middleware
|
||||||
|
- [x] 3.2 [後端] 修改 `routes/api.php`:對 `POST /api/provider/login` 套用 `throttle:5,1` middleware
|
||||||
|
- [x] 3.3 [後端] 修改 `routes/api.php`:對 `POST /api/admin/login` 套用 `throttle:3,1` middleware(管理員帳號影響範圍更廣,採用更嚴格限制)
|
||||||
|
- [x] 3.4 [後端] 執行 `php artisan route:clear` 清除路由快取
|
||||||
|
|
||||||
|
## 4. 自動化測試
|
||||||
|
|
||||||
|
- [x] 4.1 [測試] 在 `tests/Feature/AuthRateLimitTest.php` 建立 Feature test:驗證 `POST /api/member/login` 第 6 次請求回傳 HTTP 429,並斷言 response header 含 `Retry-After`
|
||||||
|
- [x] 4.2 [測試] 在同一 test file 補充:`POST /api/provider/login` 第 6 次回傳 429;`POST /api/admin/login` 第 4 次回傳 429(admin 限制為 throttle:3,1)
|
||||||
|
- [x] 4.3 [測試] 在 `tests/Feature/AuthRateLimitTest.php` 補充:正常登入(5 次以內)不受 throttle 影響,回傳 200 或 401
|
||||||
|
|
||||||
|
## 5. 手動驗證
|
||||||
|
|
||||||
|
- [x] 5.1 [整合測試] 驗證 Google OAuth 登入流程:登入後確認 Nginx access log 中 `/auth/google/callback` 的請求 URL 不含 token
|
||||||
|
- [x] 5.2 [整合測試] 驗證 Token 過期:在 `personal_access_tokens` 表手動將 `expires_at` 設為過去時間,確認 API 回傳 401 並自動登出
|
||||||
|
- [x] 5.3 [整合測試] 執行 `php artisan sanctum:prune-expired --hours=168`,確認 `personal_access_tokens` 中過期記錄被清除
|
||||||
|
- [x] 5.4 [整合測試] 驗證正常登入流程不受影響:member / provider 帳號各測試一次完整登入 → 操作 → 登出流程
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-01
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
P0 完成後,Bearer Token 有 7 天過期保護,但缺少自動續期機制,且 token 仍存於 localStorage(XSS 可讀)。此 change 引入 sliding window refresh 搭配 sessionStorage,兩者互相依賴:sessionStorage 在分頁/瀏覽器關閉後清除 token;refresh 機制確保 token 在主動使用期間不過期,兩者缺一則安全性或 UX 有所折損。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Token 在主動使用期間無限 sliding,不需手動重新登入
|
||||||
|
- Token 改存 sessionStorage,關閉分頁/瀏覽器後自動清除
|
||||||
|
- 多個並發 401 只觸發一次 refresh,其他請求排隊等待結果
|
||||||
|
- member、provider、admin 三個角色統一行為
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不引入獨立的 refresh token(避免 token 儲存複雜度倍增)
|
||||||
|
- 不實作 token rotation 以外的安全機制(如 device binding)
|
||||||
|
- 不處理 admin panel(尚未實作)的 refresh 前端邏輯
|
||||||
|
- 不改變 7 天的 token 有效期設定
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 使用現有 Bearer Token 做 refresh(無獨立 refresh token)
|
||||||
|
|
||||||
|
**選擇**:`POST /api/{role}/refresh` 接受現有 Bearer token → revoke → 發新 token
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 不需新增 DB 欄位或 token 類型
|
||||||
|
- 實作最簡單:後端 3 個端點各約 5 行
|
||||||
|
- 安全性足夠:refresh 需要有效 token,無法憑空 refresh
|
||||||
|
- 缺點:token 只要還有效就能 refresh(不區分「剛登入」vs「長期 refresh」),可接受
|
||||||
|
|
||||||
|
**替代方案**:獨立 refresh token(長效 + 短效 access token 組合)——更安全但需儲存兩個 token、多一層 DB 查詢,複雜度不值得在此階段引入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Refresh 在 axios response interceptor 觸發(401 時)
|
||||||
|
|
||||||
|
**選擇**:interceptor 攔截 401 → 呼叫 refresh → 成功則以新 token retry 原請求 → 失敗才登出
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 對所有 API 呼叫自動生效,不需每個頁面個別處理
|
||||||
|
- 延遲 refresh 到實際需要時(lazy),不需 proactive timer
|
||||||
|
|
||||||
|
**並發問題**:多個 API 同時 401 時,只允許一個 refresh 進行中。實作方式:module-level `refreshing` flag + `pendingRequests` queue。refresh 進行中的其他 401 請求排入 queue,refresh 完成後統一 retry。
|
||||||
|
|
||||||
|
**替代方案**:Proactive refresh(token 快過期前主動 refresh)——需要 timer 或每次請求檢查過期時間,複雜度較高,且 sessionStorage 無法跨分頁共享計時狀態。
|
||||||
|
|
||||||
|
#### Refresh 觸發條件
|
||||||
|
|
||||||
|
下列情況回傳 401 時**觸發** refresh:
|
||||||
|
- 任何需要認證的 API 請求(`/api/member/*`、`/api/provider/*`、`/api/admin/*`)
|
||||||
|
|
||||||
|
下列情況回傳 401 時**不觸發** refresh(直接傳遞錯誤給呼叫方):
|
||||||
|
- `/login`、`/register` 端點(帳密錯誤,非 token 問題)
|
||||||
|
- `/refresh` 端點本身(防止無限遞迴)
|
||||||
|
|
||||||
|
#### Refresh 端點接受的 token 狀態
|
||||||
|
|
||||||
|
- **接受**:`expires_at` 尚未過期、`personal_access_tokens` 中記錄存在且未被 revoke
|
||||||
|
- **拒絕**:`expires_at` 已過期、token 已被 revoke(前次 refresh 或登出時)、token 格式不合法
|
||||||
|
|
||||||
|
#### Refresh 失敗後的前端清理流程
|
||||||
|
|
||||||
|
1. 清除 `sessionStorage` 中的 `token`(與 `user`)
|
||||||
|
2. 將 `pendingRequests` queue 中所有等待的 request 以 error reject
|
||||||
|
3. 重設 `isRefreshing = false`
|
||||||
|
4. 導向 `/login`(或 `/coach/login`)
|
||||||
|
|
||||||
|
#### Interceptor 實作約束(Invariants)
|
||||||
|
|
||||||
|
| 約束 | 說明 |
|
||||||
|
|---|---|
|
||||||
|
| 同時間只允許一個 refresh | `isRefreshing` flag 確保並發 401 不重複發 refresh |
|
||||||
|
| Refresh request 不進入 refresh 流程 | 偵測 `config.url.includes('/refresh')` 跳過 |
|
||||||
|
| 每個原始 request 最多 retry 一次 | `config._retry` flag,已 retry 的請求不再觸發 refresh |
|
||||||
|
| Refresh 失敗時清除 token + 全部登出 | 清 sessionStorage → reject all pending → redirect |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. sessionStorage 取代 localStorage
|
||||||
|
|
||||||
|
**選擇**:`sessionStorage.setItem('token', ...)` 取代 `localStorage.setItem('token', ...)`
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 分頁關閉後自動清除,攻擊視窗縮短
|
||||||
|
- 重整頁面(F5)sessionStorage 仍保留,token 不會消失,不需 refresh
|
||||||
|
- Refresh 機制是為了處理 token **過期**(7 天未使用)回傳 401 的情況,與頁面重整無關
|
||||||
|
|
||||||
|
**副作用**:多個分頁不共享登入狀態(在一個分頁登入,另一個分頁需重新登入)。可接受——per-tab session 對安全性更好。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. AuthCallbackView 改用 sessionStorage
|
||||||
|
|
||||||
|
Google OAuth callback 目前直接呼叫 `localStorage.setItem('token', token)`(在 `auth.setAuth` 之前)。需同步改為 sessionStorage,否則 callback 存的位置與 axios interceptor 讀取的位置不一致。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **多分頁體驗退化**:使用者在 Tab A 登入,Tab B 不會自動登入。對大部分使用情境可接受,但若使用者習慣多分頁操作會需要適應。
|
||||||
|
- **Refresh 端點被濫用**:攻擊者持有有效 token 時可持續呼叫 refresh。refresh 端點目前**沒有 rate limiting**,這是本次 change 的明確決策:先以較低複雜度完成 sliding refresh 流程;若後續觀察到濫用或異常流量,再於 P2 補上 rate limiting。
|
||||||
|
- **Refresh 失敗的 edge case**:網路短暫中斷時 refresh 失敗 → 強制登出。比起讓使用者停在壞狀態,主動登出更安全,可接受。
|
||||||
|
- **Queue 實作複雜度**:`pendingRequests` queue 需正確處理 promise resolve/reject,實作錯誤會導致請求永久 pending。需要仔細測試。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 後端:新增 `refreshMember`、`refreshProvider`、`refreshAdmin` 方法至 `AuthController.php`,新增對應路由至 `routes/api.php`
|
||||||
|
2. 前端:改寫 `axios.js` 和 `coachAxios.js` 的 interceptor(refresh-then-retry + queue)
|
||||||
|
3. 前端:更新 `auth.js`、`coachAuth.js`、`AuthCallbackView.vue` 的 storage 讀寫改為 sessionStorage
|
||||||
|
4. 驗證:手動測試 token 過期後自動 refresh、多並發 401、分頁關閉後 token 清除
|
||||||
|
|
||||||
|
**Rollback**:各項獨立可回滾。storage 改回 localStorage 不影響功能,只影響安全性。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Refresh 端點是否需要 rate limiting?** → **決策:此 change 不加。** 持有有效 token 才能 refresh,攻擊面有限;P0 已對登入端點加 throttle。若未來觀察到濫用再加 `throttle:10,1`。
|
||||||
|
- **Admin refresh 前端邏輯是否需要?** → **決策:此 change 只建後端端點(`POST /api/admin/refresh`),不實作前端 interceptor。** Admin panel 尚未實作,前端邏輯待 admin panel 開發時一併處理。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
P0 已設定 Bearer Token 7 天過期,但目前缺乏自動續期機制——token 過期後使用者直接被踢回登入頁,且 token 持久存在 localStorage(JS 可讀,XSS 攻擊面)。引入 token refresh 搭配 sessionStorage 可同時解決「過期 UX 不佳」和「token 洩漏視窗過長」兩個問題,且兩者必須一起實作才有意義。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **後端**:新增三個 refresh 端點(`POST /api/member/refresh`、`POST /api/provider/refresh`、`POST /api/admin/refresh`),接受現有 Bearer token,revoke 舊 token 並發行新 7 天 token(sliding window)
|
||||||
|
- **前端 axios interceptor**:將現有「401 直接登出」改為「401 → 先嘗試 refresh → 成功則 retry 原始請求 → 失敗才登出」;同時處理多個並發 401 只發一次 refresh 請求
|
||||||
|
- **前端 token 儲存**:`auth.js`、`coachAuth.js` 的 localStorage 改為 sessionStorage;`axios.js`、`coachAxios.js` interceptor 讀取來源同步更新
|
||||||
|
- **`AuthCallbackView.vue`**:Google OAuth callback 存 token 改寫 sessionStorage
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `token-refresh`: 三個角色的 refresh API 端點(revoke 舊 token + 發行新 token);前端 axios refresh-then-retry 攔截邏輯,含並發 refresh 去重
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `member-portal-ui`: `認證狀態管理` 行為變更——token 從 localStorage 改存 sessionStorage;新增自動 refresh 行為(401 時先 refresh 再 retry)
|
||||||
|
- `coach-portal-ui`: `Coach 路由守衛` 讀取的 auth state 改以 sessionStorage 為來源;同樣支援 refresh-then-retry
|
||||||
|
- `provider-auth`: `Bearer Token 有效期` 新增 sliding window 行為——主動使用 API 的 session 可透過 refresh 無限延續,不需重新登入
|
||||||
|
- `admin-auth`: `管理員 Bearer Token 有效期` 同上,sliding window via refresh
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **後端**:`app/Http/Controllers/API/AuthController.php`(新增 refresh 方法)、`routes/api.php`(新增 refresh 路由)
|
||||||
|
- **前端**:`frontend/src/api/axios.js`、`frontend/src/api/coachAxios.js`(interceptor 重寫)、`frontend/src/stores/auth.js`、`frontend/src/stores/coachAuth.js`(sessionStorage)、`frontend/src/views/AuthCallbackView.vue`
|
||||||
|
- **行為差異**:sessionStorage 為 per-tab,多個分頁不共享登入狀態(與現有 localStorage 共享行為不同)
|
||||||
|
- **無 DB schema 變更**:使用現有 `personal_access_tokens` 表,refresh 透過 revoke + createToken 實作
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 管理員 Bearer Token 有效期
|
||||||
|
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token。主動使用 API 的 session 可透過 refresh 端點取得新 token(sliding window);閒置超過 7 天後需重新登入。
|
||||||
|
|
||||||
|
#### Scenario: Token 過期後管理員請求被拒絕
|
||||||
|
- **WHEN** 管理員使用已過期(超過 7 天未 refresh)的 token 送出 API 請求
|
||||||
|
- **THEN** 回傳 HTTP 401,token 視為無效
|
||||||
|
|
||||||
|
#### Scenario: 有效期內 token 正常通過認證
|
||||||
|
- **WHEN** 管理員使用未過期的 token 送出 API 請求
|
||||||
|
- **THEN** 請求正常通過認證,回傳對應資源
|
||||||
|
|
||||||
|
#### Scenario: Refresh 延續有效期
|
||||||
|
- **WHEN** 管理員在 token 過期前呼叫 `POST /api/admin/refresh`
|
||||||
|
- **THEN** 取得新的 7 天 token,舊 token 失效,有效期重置
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Coach 路由守衛
|
||||||
|
前端 SHALL 對所有 `/coach/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/coach/login`。auth state 以 sessionStorage 為來源,API 請求支援 refresh-then-retry。
|
||||||
|
|
||||||
|
#### Scenario: 未登入訪問 Dashboard
|
||||||
|
- **WHEN** 未登入使用者直接訪問 `/coach/dashboard`
|
||||||
|
- **THEN** 自動導向 `/coach/login`
|
||||||
|
|
||||||
|
#### Scenario: 分頁關閉後重開需重新登入
|
||||||
|
- **WHEN** 教練關閉分頁後重新開啟 `/coach/dashboard`
|
||||||
|
- **THEN** sessionStorage 已清除,自動導向 `/coach/login`
|
||||||
|
|
||||||
|
#### Scenario: 登出
|
||||||
|
- **WHEN** 教練點擊登出
|
||||||
|
- **THEN** 呼叫 `POST /api/provider/logout`,清除 sessionStorage coach_token / coach_user,導向 `/coach/login`
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 認證狀態管理
|
||||||
|
前端 SHALL 使用 Pinia store 管理認證狀態,token 存於 sessionStorage(非 localStorage),並在所有需認證的 API 請求自動附加 Bearer token。收到 401 時先嘗試 refresh,成功後 retry;refresh 失敗才清除 session 並導向登入頁。
|
||||||
|
|
||||||
|
#### Scenario: 頁面刷新後保持登入狀態
|
||||||
|
- **WHEN** 已登入使用者在同一分頁重新整理頁面
|
||||||
|
- **THEN** 從 sessionStorage 還原 token,使用者仍為登入狀態
|
||||||
|
|
||||||
|
#### Scenario: 分頁關閉後 token 自動清除
|
||||||
|
- **WHEN** 使用者關閉瀏覽器分頁或瀏覽器
|
||||||
|
- **THEN** sessionStorage 自動清除,重新開啟需重新登入
|
||||||
|
|
||||||
|
#### Scenario: Token 過期後自動 refresh
|
||||||
|
- **WHEN** API 請求因 token 過期回傳 401
|
||||||
|
- **THEN** 自動呼叫 `POST /api/member/refresh`,成功後 retry 原始請求,使用者無感知
|
||||||
|
|
||||||
|
#### Scenario: 登出
|
||||||
|
- **WHEN** 使用者點擊登出
|
||||||
|
- **THEN** 呼叫 `POST /api/member/logout`,清除 sessionStorage token,導向 `/login`
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bearer Token 有效期
|
||||||
|
後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token。主動使用 API 的 session 可透過 refresh 端點取得新 token(sliding window),持續使用不需重新登入;閒置超過 7 天後 token 過期,需重新登入。
|
||||||
|
|
||||||
|
#### Scenario: Token 過期後請求被拒絕
|
||||||
|
- **WHEN** 教練使用已過期(超過 7 天未 refresh)的 token 送出 API 請求
|
||||||
|
- **THEN** 回傳 HTTP 401,token 視為無效
|
||||||
|
|
||||||
|
#### Scenario: 有效期內 token 正常運作
|
||||||
|
- **WHEN** 教練使用未過期的 token 送出 API 請求
|
||||||
|
- **THEN** 請求正常通過認證
|
||||||
|
|
||||||
|
#### Scenario: Refresh 延續有效期
|
||||||
|
- **WHEN** 教練在 token 過期前呼叫 `POST /api/provider/refresh`
|
||||||
|
- **THEN** 取得新的 7 天 token,舊 token 失效,有效期重置
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Member Token Refresh
|
||||||
|
後端 SHALL 提供 `POST /api/member/refresh`(需有效 Bearer token),revoke 現有 token 並發行新的 7 天 token。
|
||||||
|
|
||||||
|
#### Scenario: 有效 token refresh 成功
|
||||||
|
- **WHEN** 已登入會員以有效 Bearer token 呼叫 `POST /api/member/refresh`
|
||||||
|
- **THEN** 回傳 HTTP 200,`{ status: true, data: { token, token_type: "Bearer" } }`,舊 token 同時失效
|
||||||
|
|
||||||
|
#### Scenario: 無效或過期 token 無法 refresh
|
||||||
|
- **WHEN** 以過期或無效的 token 呼叫 refresh 端點
|
||||||
|
- **THEN** 回傳 HTTP 401,`{ status: false, message: "無效的 token" }`
|
||||||
|
|
||||||
|
#### Scenario: 非 member 角色無法使用 member refresh
|
||||||
|
- **WHEN** role=provider 的 token 呼叫 `/api/member/refresh`
|
||||||
|
- **THEN** 回傳 HTTP 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Provider Token Refresh
|
||||||
|
後端 SHALL 提供 `POST /api/provider/refresh`(需有效 Bearer token),revoke 現有 token 並發行新的 7 天 token。
|
||||||
|
|
||||||
|
#### Scenario: 有效 token refresh 成功
|
||||||
|
- **WHEN** 已登入教練以有效 Bearer token 呼叫 `POST /api/provider/refresh`
|
||||||
|
- **THEN** 回傳 HTTP 200,`{ status: true, data: { token, token_type: "Bearer" } }`,舊 token 同時失效
|
||||||
|
|
||||||
|
#### Scenario: 無效或過期 token 無法 refresh
|
||||||
|
- **WHEN** 以過期或無效的 token 呼叫 refresh 端點
|
||||||
|
- **THEN** 回傳 HTTP 401
|
||||||
|
|
||||||
|
#### Scenario: 非 provider 角色無法使用 provider refresh
|
||||||
|
- **WHEN** role=member 的 token 呼叫 `/api/provider/refresh`
|
||||||
|
- **THEN** 回傳 HTTP 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Admin Token Refresh
|
||||||
|
後端 SHALL 提供 `POST /api/admin/refresh`(需有效 Bearer token),revoke 現有 token 並發行新的 7 天 token。
|
||||||
|
|
||||||
|
#### Scenario: 有效 token refresh 成功
|
||||||
|
- **WHEN** 已登入管理員以有效 Bearer token 呼叫 `POST /api/admin/refresh`
|
||||||
|
- **THEN** 回傳 HTTP 200,`{ status: true, data: { token, token_type: "Bearer" } }`,舊 token 同時失效
|
||||||
|
|
||||||
|
#### Scenario: 無效或過期 token 無法 refresh
|
||||||
|
- **WHEN** 以過期或無效的 token 呼叫 refresh 端點
|
||||||
|
- **THEN** 回傳 HTTP 401
|
||||||
|
|
||||||
|
#### Scenario: 非 admin 角色無法使用 admin refresh
|
||||||
|
- **WHEN** role=member 或 role=provider 的 token 呼叫 `/api/admin/refresh`
|
||||||
|
- **THEN** 回傳 HTTP 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 前端 Refresh-Then-Retry 攔截
|
||||||
|
Member(`axios.js`)與 Provider/Coach(`coachAxios.js`)的 axios interceptor SHALL 在收到 401 時先嘗試 refresh,成功後以新 token retry 原始請求;refresh 失敗才清除 token 並導向登入頁。同時多個 401 只觸發一次 refresh,其餘請求排隊等待結果。Admin 前端 interceptor 不在此 change 範圍內(Admin Panel 尚未實作)。
|
||||||
|
|
||||||
|
#### Scenario: 401 觸發 refresh 並 retry 成功
|
||||||
|
- **WHEN** API 請求回傳 401 且 refresh 端點回傳新 token
|
||||||
|
- **THEN** 以新 token 重送原始請求,使用者無感知(不被導向登入頁)
|
||||||
|
|
||||||
|
#### Scenario: Refresh 失敗後登出
|
||||||
|
- **WHEN** API 請求回傳 401 且 refresh 端點也回傳 401
|
||||||
|
- **THEN** 清除 sessionStorage token,導向登入頁
|
||||||
|
|
||||||
|
#### Scenario: 多個並發 401 只觸發一次 refresh
|
||||||
|
- **WHEN** 同時有多個 API 請求收到 401
|
||||||
|
- **THEN** 只發出一次 refresh 請求,其他請求等待 refresh 完成後統一以新 token retry
|
||||||
|
|
||||||
|
#### Scenario: 登入與 Refresh 端點的 401 不觸發 refresh
|
||||||
|
- **WHEN** `/login`、`/register` 或 `/refresh` 端點本身回傳 401
|
||||||
|
- **THEN** 不觸發 refresh,直接將錯誤傳遞給呼叫方(防止無限遞迴)
|
||||||
|
|
||||||
|
#### Scenario: 已 retry 的請求不再觸發 refresh
|
||||||
|
- **WHEN** 某請求已經過一次 refresh-retry,retry 後仍回傳 401
|
||||||
|
- **THEN** 不再嘗試 refresh,直接 reject 並導向登入頁
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## 1. 後端 Refresh 端點
|
||||||
|
|
||||||
|
- [x] 1.1 [後端] 在 `app/Http/Controllers/API/AuthController.php` 新增 `refreshMember(Request $request)`:驗證 role=member,revoke 當前 token,發行新 token,回傳 `{ status: true, data: { token, token_type: "Bearer" } }`
|
||||||
|
- [x] 1.2 [後端] 同上新增 `refreshProvider(Request $request)`:驗證 role=provider,revoke + 發新 token
|
||||||
|
- [x] 1.3 [後端] 同上新增 `refreshAdmin(Request $request)`:驗證 role=admin,revoke + 發新 token
|
||||||
|
- [x] 1.4 [後端] 在 `routes/api.php` 新增三個 refresh 路由(需 auth:sanctum middleware):`POST /api/member/refresh`、`POST /api/provider/refresh`、`POST /api/admin/refresh`
|
||||||
|
- [x] 1.5 [後端] 執行 `php artisan route:clear` 確認路由生效
|
||||||
|
|
||||||
|
## 2. 前端 Interceptor 改寫(Member)
|
||||||
|
|
||||||
|
- [x] 2.1 [前端] 改寫 `frontend/src/api/axios.js`:response interceptor 改為 refresh-then-retry 邏輯,加入 `isRefreshing` flag 與 `pendingRequests` queue 防止並發重複 refresh
|
||||||
|
- [x] 2.2 [前端] 確認 `axios.js` 的 request interceptor 改從 `sessionStorage` 讀取 `token`(非 localStorage)
|
||||||
|
|
||||||
|
## 3. 前端 Interceptor 改寫(Coach)
|
||||||
|
|
||||||
|
- [x] 3.1 [前端] 改寫 `frontend/src/api/coachAxios.js`:response interceptor 改為 refresh-then-retry,呼叫 `POST /api/provider/refresh`,含 `isRefreshing` + queue 邏輯
|
||||||
|
- [x] 3.2 [前端] 確認 `coachAxios.js` 的 request interceptor 改從 `sessionStorage` 讀取 `coach_token`
|
||||||
|
|
||||||
|
## 4. 前端 Store 改為 sessionStorage
|
||||||
|
|
||||||
|
- [x] 4.1 [前端] 修改 `frontend/src/stores/auth.js`:`init()`、`setAuth()`、`logout()` 中所有 `localStorage` 改為 `sessionStorage`(keys: `token`、`user`)
|
||||||
|
- [x] 4.2 [前端] 修改 `frontend/src/stores/coachAuth.js`:同上,keys: `coach_token`、`coach_user`
|
||||||
|
- [x] 4.3 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:第 21 行 `localStorage.setItem('token', token)` 改為 `sessionStorage.setItem('token', token)`
|
||||||
|
|
||||||
|
## 5. 自動化測試
|
||||||
|
|
||||||
|
- [x] 5.1 [測試] 新增 `tests/Feature/TokenRefreshTest.php`:驗證 `POST /api/member/refresh` 以有效 token 回傳新 token,且舊 token 同時失效(再打一次原 token 回 401)
|
||||||
|
- [x] 5.2 [測試] 同檔案補充:`/api/provider/refresh` 和 `/api/admin/refresh` 相同行為;跨角色呼叫(member token 打 provider/refresh)回 403
|
||||||
|
- [x] 5.3 [測試] 同檔案補充:過期 token 呼叫 refresh 回 401;已 revoke token 呼叫 refresh 回 401
|
||||||
|
|
||||||
|
## 6. 手動驗證
|
||||||
|
|
||||||
|
- [x] 6.1 [整合測試] Token 過期行為確認:將 `expires_at` 改成過去時間,打 API 確認被踢回 `/login`(refresh 端點同樣受 auth:sanctum 保護,過期 token 無法 refresh,此為預期行為。Sliding window 由 7 天有效期自然實現,主動使用期間不會到期)
|
||||||
|
- [x] 6.2 [整合測試] 多並發 401 只 refresh 一次:DevTools Network 確認只有一個 `/refresh` 請求,其他請求等待後以新 token 完成
|
||||||
|
- [x] 6.3 [整合測試] Refresh 失敗全部請求正確 reject:revoke 所有 token 後觸發 API,確認被踢回登入頁且沒有無限 loop
|
||||||
|
- [x] 6.4 [整合測試] OAuth callback 改為 sessionStorage:Google 登入後用 DevTools Application 確認 token 在 sessionStorage(非 localStorage)
|
||||||
|
- [x] 6.5 [整合測試] 關閉分頁後 token 消失:登入後關閉分頁重開,確認 sessionStorage 已清空需重新登入
|
||||||
|
- [x] 6.6 [整合測試] Role endpoint 對應正確:member token 打 `/api/provider/refresh` 回 403,不會混用
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-02
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
現有防護分兩層:
|
||||||
|
- **IP throttle**(P0):middleware `throttle:5,1` / `throttle:3,1`,防同一 IP 在短時間大量嘗試
|
||||||
|
- **Session Token**(P1):token 7 天過期,sessionStorage 存放,refresh interceptor
|
||||||
|
|
||||||
|
缺口一:攻擊者用多 IP 對同一帳號暴破,IP throttle 完全無效。
|
||||||
|
缺口二:OAuth redirect URL 若遭竄改(CSRF),惡意方可替換 authorization code。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 帳號連續失敗 5 次 → 鎖定 15 分鐘,鎖定期間任何密碼登入均拒絕
|
||||||
|
- 鎖定計數以 Cache 存放(key 包含 `email`),TTL = 鎖定時間
|
||||||
|
- 登入成功後清除失敗計數
|
||||||
|
- OAuth redirect 帶 state,callback 驗 state,不符回傳 400
|
||||||
|
- 前端顯示 423 對應的帳號鎖定訊息
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Admin 帳號鎖定(Admin Panel 尚未完成)
|
||||||
|
- 電子郵件通知(鎖定時通知帳號持有人)
|
||||||
|
- Admin 手動解鎖端點(此 change 暫不實作;時間到自動解鎖即可)
|
||||||
|
- Lockout 事件 audit log
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1:失敗計數存 Cache(not DB)
|
||||||
|
|
||||||
|
**選擇**:`Cache::increment()` / `Cache::put()`,key = `login_failures:{email}`,TTL = 鎖定時間(15 min)。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 不需要永久儲存,TTL = 鎖定時間,到期自動解鎖,零額外維護
|
||||||
|
- DB 方案需 migration + GC 排程,複雜度高 3 倍
|
||||||
|
- Cache driver 目前為 file(dev)/ Redis(prod),兩者皆可用
|
||||||
|
|
||||||
|
**替代方案**:在 `users` 資料表新增 `failed_login_count` / `locked_until` 欄位 → 否決,因為要 migration,且鎖定狀態不需跨重啟持久化。
|
||||||
|
|
||||||
|
### D2:鎖定參數放 config
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/auth_lockout.php
|
||||||
|
return [
|
||||||
|
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
|
||||||
|
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:不同環境(測試 = 100 次)可靠 env 覆蓋,不用改程式碼。
|
||||||
|
|
||||||
|
### D3:HTTP 423 Locked(帳號鎖定)
|
||||||
|
|
||||||
|
**選擇**:回傳 HTTP 423,body `{ status: false, message: "帳號已暫時鎖定,請於 X 分鐘後再試", locked_until: "<ISO8601>" }`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 422 / 401 語義不清,423 在 WebDAV 語境下即「資源被鎖定」
|
||||||
|
- 前端依 423 顯示靜態鎖定提示(`message` 欄位),不實作倒數;`locked_until` timestamp 保留在 response 供未來擴充
|
||||||
|
|
||||||
|
### D4:OAuth state 存 Laravel Session(不存 Cache)
|
||||||
|
|
||||||
|
**選擇**:`session()->put('oauth_state', $state)` 於 redirect;callback 讀取並 `session()->pull('oauth_state')` 後以 `hash_equals` 比對。
|
||||||
|
|
||||||
|
**實作細節**:Socialite 的 `redirect()` 內部會自行產生 random state 並寫入 `session('state')`,但 URL 裡已被 `.with(['state' => $state])` 換成我們的 state。若不覆蓋,Socialite 的 `user()` 做內建驗證時會因 session 值與 URL 值不符拋出 `InvalidStateException`。解法:在 `redirect()` 呼叫之後、return 之前,再呼叫 `session()->put('state', $state)` 覆蓋,讓兩層驗證(手動 + Socialite 內建)使用同一 state 值。`stateless()` 因此可完全移除。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- state 是 per-request 一次性值,Session 語義完全對應
|
||||||
|
- 移除 `stateless()` 讓 Socialite 內建驗證也生效,雙重保護
|
||||||
|
- Cache 方案需自行管理 TTL 與 key 命名,複雜度無謂提升
|
||||||
|
|
||||||
|
**替代方案**:PKCE flow → 需要前端配合改 OAuth 啟動方式,改動面太大,此 change 不採用。
|
||||||
|
|
||||||
|
### D5:不移除現有 IP throttle
|
||||||
|
|
||||||
|
帳號鎖定與 IP throttle 並行:IP throttle 先觸發(429),之後才會累積到帳號鎖定(423)。兩者互不衝突,都保留。
|
||||||
|
|
||||||
|
### D6:Email 正規化規則
|
||||||
|
|
||||||
|
**選擇**:所有 Cache key 使用前,先對 email 執行 `strtolower(trim($email))`。
|
||||||
|
|
||||||
|
**理由**:`User@Gmail.com` 與 `user@gmail.com` 在 RFC 5321 語境下是同一信箱,不正規化會導致攻擊者對同一帳號用大小寫變體繞過計數上限。Trim 防止前後空白導致 key 差異。
|
||||||
|
|
||||||
|
**範圍**:同規則套用到計數 key、expires_at key、以及查詢 DB 前的 email 比對。Laravel Eloquent `where('email', ...)` 若 DB collation 為 `utf8mb4_unicode_ci` 則大小寫不敏感,但 Cache key 必須額外正規化。
|
||||||
|
|
||||||
|
### D7:不存在帳號不累計失敗計數(已確認)
|
||||||
|
|
||||||
|
**選擇**:若 DB 查無此 email,**不**遞增 Cache 計數,但仍回傳 HTTP 401 並使用與密碼錯誤**相同**的泛用訊息(`"電子郵件或密碼錯誤"`),不揭露帳號是否存在。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 鎖定機制目的是保護真實帳號;不存在的帳號無帳可鎖
|
||||||
|
- 為不存在帳號累計計數會造成 Cache 污染,且攻擊者可製造大量假 email 讓 Cache 暴增
|
||||||
|
- 統一 401 訊息確保攻擊者無法透過錯誤回應的差異做帳號枚舉(account enumeration)
|
||||||
|
- 已知殘留風險:攻擊者對真實帳號觸發 5 次失敗後可從 423 推斷帳號存在,但此操作本身已受 IP throttle 限速,實際攻擊成本高
|
||||||
|
|
||||||
|
**邊界對照**:帳號不存在 → 401(不遞增);帳號存在但密碼錯 → 401(遞增);帳號已鎖定 → 423;兩種 401 的 response body 訊息相同。
|
||||||
|
|
||||||
|
### D8:TTL / Window 語義 — Fixed Window from First Failure
|
||||||
|
|
||||||
|
**選擇**:採用 **Fixed window**,起算點為「**第一次失敗**」。
|
||||||
|
|
||||||
|
具體規則:
|
||||||
|
1. 查 Cache key `login_failures:{role}:{email}`(email 已正規化)
|
||||||
|
2. 若 key **不存在**(count = 0,或 window 已過期):
|
||||||
|
- `Cache::put('login_failures:{role}:{email}', 1, $decay_minutes * 60)`
|
||||||
|
- 同時寫入 `login_expires_at:{role}:{email}` = `now()->addMinutes($decay_minutes)->toIso8601String()`,TTL 相同
|
||||||
|
3. 若 key **存在**(count ≥ 1):
|
||||||
|
- `Cache::increment('login_failures:{role}:{email}')`(**不重設 TTL**,window 不延長)
|
||||||
|
4. 若遞增後 count ≥ max_attempts:回傳 HTTP 423
|
||||||
|
|
||||||
|
**理由**:Sliding window(每次失敗延長 TTL)對合法使用者懲罰過重——誤輸密碼後,每一次重試都重置等待時間,實際上永遠解不了鎖。Fixed window 可預測:「最多等 decay_minutes 分鐘」,對使用者友善。
|
||||||
|
|
||||||
|
**替代方案**:Sliding window → 否決,原因同上。
|
||||||
|
|
||||||
|
### D9:`locked_until` 的資料來源 — Companion Key
|
||||||
|
|
||||||
|
**選擇**:第一次失敗時同步寫入 companion key `login_expires_at:{role}:{email}`,值為 ISO 8601 字串,TTL 與計數 key 相同。每次回傳 HTTP 423 時讀取此 key 作為 `locked_until` 欄位值。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- Laravel Cache 不保證所有 driver 提供 `getExpiry()` API(file driver 無此方法)
|
||||||
|
- Companion key 方案不依賴 driver 實作細節,跨 file / Redis driver 行為一致
|
||||||
|
- 兩個 key 的 TTL 相同,到期時同步消失,無需額外清理
|
||||||
|
|
||||||
|
**Fallback**:若 `login_expires_at` key 因 driver 不一致或 race condition 缺失,fallback 為 `now()->addMinutes($decay_minutes)->toIso8601String()` 並寫 `Log::warning`。
|
||||||
|
|
||||||
|
### D10:Cache Key Namespace 正式定義
|
||||||
|
|
||||||
|
**完整 key 格式**:
|
||||||
|
|
||||||
|
| Key 用途 | 格式 | 有效 role 值 |
|
||||||
|
|---------|------|------------|
|
||||||
|
| 失敗計數 | `login_failures:{role}:{normalized_email}` | `member`、`provider` |
|
||||||
|
| 鎖定到期時間 | `login_expires_at:{role}:{normalized_email}` | `member`、`provider` |
|
||||||
|
|
||||||
|
`{role}` 的值與 API 路徑對應:`/api/member/login` → `member`;`/api/provider/login` → `provider`。`admin` 明確不在此 change 範圍,不建立任何相關 key。
|
||||||
|
|
||||||
|
`{normalized_email}` = `strtolower(trim($request->email))`,於 controller 頂部完成正規化,後續所有 Cache 操作一律使用正規化後的值。
|
||||||
|
|
||||||
|
### D11:登入端點 Response Semantics 完整對照表
|
||||||
|
|
||||||
|
以下為 Member / Provider 登入端點所有可能回應的**完整定義**,用於消除實作端的歧義:
|
||||||
|
|
||||||
|
| 情境 | 檢查順序 | HTTP | Response Body |
|
||||||
|
|------|---------|------|---------------|
|
||||||
|
| 帳號已在目前 lockout window 內(lockout active) | 1st | 423 | `{ status: false, message: "帳號已暫時鎖定,請於 N 分鐘後再試", locked_until: "<ISO8601>" }` |
|
||||||
|
| 帳號不存在(DB 查無 email) | 2nd | 401 | `{ status: false, message: "電子郵件或密碼錯誤" }` |
|
||||||
|
| 帳號存在,密碼錯誤,失敗 1–4 次(未達閾值) | 3rd | 401 | `{ status: false, message: "電子郵件或密碼錯誤" }` |
|
||||||
|
| 帳號存在,密碼錯誤,**第 5 次**(觸發鎖定) | 3rd | 423 | `{ status: false, message: "帳號已暫時鎖定,請於 N 分鐘後再試", locked_until: "<ISO8601>" }` |
|
||||||
|
| 帳號存在,密碼正確 | 3rd | 200 | `{ status: true, data: { token: "...", user: {...} } }` |
|
||||||
|
|
||||||
|
**檢查順序說明**:
|
||||||
|
1. 先查 Cache 判斷是否已鎖定(最快路徑,不進 DB)
|
||||||
|
2. 再查 DB 確認帳號存在(帳號不存在 → 401,不遞增計數)
|
||||||
|
3. 最後驗證密碼(錯誤 → 遞增計數 → 若達閾值回 423,否則 401;正確 → 200 + 清計數)
|
||||||
|
|
||||||
|
`message` 中的 `N` 為靜態設定值(`decay_minutes`),不是即時計算的剩餘分鐘數。前端**不實作**剩餘時間倒數;`locked_until` 欄位保留於 response 以備未來若需精確倒數時使用。
|
||||||
|
|
||||||
|
### D12:OAuth 多分頁並發 Flow 的預期行為
|
||||||
|
|
||||||
|
**選擇**:同一瀏覽器同一 session 下,**只有最後一個啟動的 OAuth flow 能成功**;先啟動的 tab 的 callback 因 state 不符而收到 HTTP 400,前端引導使用者重新操作。此行為為**設計預期**,不視為 bug。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- Laravel session 在同一瀏覽器共用(相同 session cookie),後寫的 `oauth_state` 覆蓋先寫的
|
||||||
|
- 支援多個並發 state 需在 session 中維護 state 陣列(push/find/remove),複雜度不符現階段需求
|
||||||
|
- 使用者實際上幾乎不會在兩個 tab 同時進行 Google OAuth;若發生,400 → redirect `/login?error=oauth_failed` 是合理的 UX 引導
|
||||||
|
|
||||||
|
**替代方案**:Session 存 state 陣列(每次 redirect 前 push,callback 時 find & remove)→ 否決,目前無真實使用場景,過度設計。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[DoS 風險] 攻擊者刻意鎖定合法帳號** → 攻擊者只要知道 email 就能讓帳號鎖 15 分鐘。緩解:IP throttle 已先擋,15 分鐘鎖定時間短,影響有限;金流上線前再評估是否加 CAPTCHA。
|
||||||
|
- **[Account Enumeration Trade-off] 不存在帳號不累計失敗計數** → 可避免攻擊者以大量假 email 汙染 Cache,但攻擊者理論上仍可透過 repeated attempts 是否最終進入 423,間接推測帳號存在與否。本 change 接受此風險,先以統一 401 訊息與既有 IP throttle 緩解;若未來威脅模型提高,再評估改為對不存在帳號也累計或加入 CAPTCHA。
|
||||||
|
- **[Cache 遺失] file cache 重啟後失效** → 鎖定計數不持久,重啟即解鎖。生產環境預設使用 Redis(`.env` `CACHE_DRIVER=redis`),計數跨容器共享且重啟不失效;file cache 可運作但效果較差(計數不跨容器、重啟清零),僅適用於 dev 環境。
|
||||||
|
- **[Session 相容] 移除 stateless() 後需 session driver 正常運作** → Docker 環境 session driver 使用 file,確認 storage/framework/sessions 有寫入權限即可。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 部署時無 DB migration(Cache 方案不需 migration)
|
||||||
|
2. **生產環境** `.env` 確認 `CACHE_DRIVER=redis`(Redis 為生產預設,確保計數跨容器共享)
|
||||||
|
3. `.env` 可選加:`LOCKOUT_MAX_ATTEMPTS=5`、`LOCKOUT_DECAY_MINUTES=15`(有預設值,不加也可)
|
||||||
|
4. OAuth state 改動:session driver 已在 dev 正常使用,無需額外設定
|
||||||
|
5. Rollback:移除兩個 feature 的 controller 邏輯,恢復 `stateless()`;不需 DB rollback
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
~~是否在前端 LoginView 顯示「剩餘鎖定時間」倒數?~~ → **已決定**:不實作倒數,只顯示靜態 `message`;`locked_until` 保留於 response 供未來擴充。
|
||||||
|
|
||||||
|
~~生產環境 Cache driver 是否確定為 Redis?~~ → **已決定**:生產環境預設 Redis(`CACHE_DRIVER=redis`);file cache 可運作但效果較差,不建議用於生產。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user