fix: Custom step navigation bugs - go-back, descendants, redundant checkbox
- Show previously-created custom steps as clickable options on decision nodes so they remain accessible after going back - Fix breadcrumb to show custom step titles instead of raw UUIDs - Fix ContinuationModal to show grandchildren (two levels deep) instead of immediate children that duplicate option labels - Remove redundant "Save to Library" checkbox from StepForm since PostStepActionModal now handles that decision Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -205,3 +205,8 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Railway CLI (local tooling)
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Purpose:** This file documents bugs, fixes, and gotchas encountered during development.
|
||||
> **For Claude Code:** Read this file at the start of each session to avoid repeating past mistakes.
|
||||
> **Last Updated:** January 30, 2026
|
||||
> **Last Updated:** February 1, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -570,6 +570,160 @@ pip install httpx
|
||||
|
||||
---
|
||||
|
||||
## Railway Deployment
|
||||
|
||||
### DATABASE_URL_SYNC Must Derive from DATABASE_URL ⚠️ CRITICAL
|
||||
**Problem:** Alembic migrations run but connect to localhost instead of Railway's Postgres. Database tables never get created.
|
||||
|
||||
**Cause:** `DATABASE_URL_SYNC` was a separate pydantic field with a default value pointing to `localhost:5432`. Railway only provides `DATABASE_URL`, so the sync version wasn't being set correctly.
|
||||
|
||||
**Solution:** Make `DATABASE_URL_SYNC` a property that derives from `DATABASE_URL`:
|
||||
```python
|
||||
# BAD: Separate field with default
|
||||
DATABASE_URL_SYNC: str = "postgresql://postgres:postgres@localhost:5432/patherly"
|
||||
|
||||
# GOOD: Property that derives from DATABASE_URL
|
||||
@property
|
||||
def DATABASE_URL_SYNC(self) -> str:
|
||||
"""Get sync URL by removing asyncpg prefix from DATABASE_URL."""
|
||||
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
```
|
||||
|
||||
**Files affected:** `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
### Railway releaseCommand May Not Execute
|
||||
**Problem:** Migrations in `railway.toml` `releaseCommand` don't run. Deploy logs show the app starting but no migration output.
|
||||
|
||||
**Cause:** Railway's `releaseCommand` feature may not work reliably with all configurations, especially with Dockerfile builds.
|
||||
|
||||
**Solution:** Run migrations in the Docker CMD instead:
|
||||
```dockerfile
|
||||
# Instead of relying on railway.toml releaseCommand:
|
||||
CMD alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
|
||||
```
|
||||
|
||||
**Files affected:** `backend/Dockerfile`, `backend/railway.toml` (keep releaseCommand as backup)
|
||||
|
||||
---
|
||||
|
||||
### Alembic env.py Must Import All Models
|
||||
**Problem:** Migration runs but table isn't created. Or migration fails with model-related errors.
|
||||
|
||||
**Cause:** Alembic's `env.py` only discovers models that are imported. If you add a new model (like `InviteCode`), you must add it to the imports.
|
||||
|
||||
**Solution:** Add new models to the import statement in `alembic/env.py`:
|
||||
```python
|
||||
# BEFORE: Missing InviteCode
|
||||
from app.models import User, Team, Tree, Session, Attachment
|
||||
|
||||
# AFTER: All models imported
|
||||
from app.models import User, Team, Tree, Session, Attachment, InviteCode
|
||||
```
|
||||
|
||||
**Files affected:** `backend/alembic/env.py`
|
||||
|
||||
---
|
||||
|
||||
### New Users Default to "engineer" Role, Not Admin
|
||||
**Problem:** After registering, user cannot access admin-only endpoints (like creating invite codes). Returns 403 Forbidden.
|
||||
|
||||
**Cause:** The User model defaults to `role="engineer"`. The first user isn't automatically made admin.
|
||||
|
||||
**Solution:** Manually promote user to admin via Railway's Postgres Query tab:
|
||||
```sql
|
||||
UPDATE users SET role = 'admin' WHERE email = 'your@email.com';
|
||||
```
|
||||
|
||||
**Then log out and back in** to get a new JWT with the updated role.
|
||||
|
||||
**Files affected:** Database (manual update required for first admin)
|
||||
|
||||
---
|
||||
|
||||
### Frontend VITE_API_URL Must Use HTTPS, No Port
|
||||
**Problem:** Frontend gets "Network Error" when trying to call API on Railway.
|
||||
|
||||
**Cause:** Common mistakes:
|
||||
- Using `http://` instead of `https://`
|
||||
- Including port number (`:8080`) when Railway handles routing
|
||||
- Typos in variable names (e.g., `FRONTED_URL` vs `FRONTEND_URL`)
|
||||
|
||||
**Solution:** Ensure correct format:
|
||||
```
|
||||
# WRONG
|
||||
VITE_API_URL=http://api.patherly.com:8080
|
||||
VITE_API_URL=http://api.patherly.com
|
||||
|
||||
# CORRECT
|
||||
VITE_API_URL=https://api.patherly.com
|
||||
```
|
||||
|
||||
**Note:** This is a **build-time** variable. After changing it, you must trigger a new frontend build/deploy.
|
||||
|
||||
---
|
||||
|
||||
### Swagger OAuth2 Password Flow Returns 401
|
||||
**Problem:** Clicking "Authorize" in Swagger UI with username/password returns 401, even with correct credentials.
|
||||
|
||||
**Workaround:** Use the login endpoint directly instead:
|
||||
1. Call `POST /api/v1/auth/login` with your credentials
|
||||
2. Copy the `access_token` from the response
|
||||
3. Click "Authorize" button
|
||||
4. Paste just the token (no "Bearer" prefix) in the authorization field
|
||||
|
||||
**Files affected:** This is a Swagger UI / FastAPI OAuth2 configuration quirk
|
||||
|
||||
---
|
||||
|
||||
### Seed Script: Use --email/--password Instead of Creating User
|
||||
**Problem:** Seed script fails when `REQUIRE_INVITE_CODE=true` because it tries to register a new seed user without an invite code.
|
||||
|
||||
**Solution:** Modified seed script to accept admin credentials via CLI arguments:
|
||||
```bash
|
||||
python -m scripts.seed_trees \
|
||||
--api-url https://api.patherly.com/api/v1 \
|
||||
--email admin@patherly.com \
|
||||
--password "your-password"
|
||||
```
|
||||
|
||||
**Files affected:** `backend/scripts/seed_trees.py`
|
||||
|
||||
---
|
||||
|
||||
### Seed Script: Use venv Python, Not System Python
|
||||
**Problem:** Running `python -m scripts.seed_trees` fails with `ModuleNotFoundError: No module named 'httpx'` even after installing httpx.
|
||||
|
||||
**Cause:** Installing with system Python (`pip install httpx`) but running with venv Python, or vice versa.
|
||||
|
||||
**Solution:** Always use the venv's Python explicitly:
|
||||
```powershell
|
||||
# Use venv Python directly
|
||||
./venv/Scripts/python -m scripts.seed_trees --api-url ... --email ... --password ...
|
||||
|
||||
# Or ensure venv is activated first
|
||||
.\venv\Scripts\Activate
|
||||
python -m scripts.seed_trees ...
|
||||
```
|
||||
|
||||
**Files affected:** Any script in `backend/scripts/`
|
||||
|
||||
---
|
||||
|
||||
### Railway Environment Variable Naming
|
||||
**Problem:** Environment variable isn't being read by the application.
|
||||
|
||||
**Checklist:**
|
||||
- Case-sensitive: `FRONTEND_URL` ≠ `frontend_url` ≠ `Frontend_Url`
|
||||
- No typos: `FRONTEND_URL` ≠ `FRONTED_URL`
|
||||
- Empty string vs not set: Setting a variable to empty string is different from not setting it
|
||||
- Delete stale variables: If you changed a variable from a field to a property (like `DATABASE_URL_SYNC`), delete the Railway variable
|
||||
|
||||
**Files affected:** Railway dashboard → Service → Variables tab
|
||||
|
||||
---
|
||||
|
||||
## Adding New Lessons
|
||||
|
||||
When you encounter and fix a bug, add it here with:
|
||||
|
||||
@@ -36,9 +36,7 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleFormSubmit = async (data: StepCreate, _saveToLibrary: boolean) => {
|
||||
// Note: saveToLibrary preference is no longer used here - the PostStepActionModal
|
||||
// handles the decision of whether to save to library, use now, or both
|
||||
const handleFormSubmit = async (data: StepCreate) => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { stepCategoriesApi } from '@/api'
|
||||
import type { StepCreate, StepCategory, StepCommand } from '@/types/step'
|
||||
|
||||
interface StepFormProps {
|
||||
onSubmit: (data: StepCreate, saveToLibrary: boolean) => void
|
||||
onSubmit: (data: StepCreate) => void
|
||||
onCancel: () => void
|
||||
initialData?: Partial<StepCreate>
|
||||
}
|
||||
@@ -31,8 +31,6 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
const [visibility, setVisibility] = useState<'private' | 'team' | 'public'>(
|
||||
initialData?.visibility || 'private'
|
||||
)
|
||||
const [saveToLibrary, setSaveToLibrary] = useState(true)
|
||||
|
||||
// Categories
|
||||
const [categories, setCategories] = useState<StepCategory[]>([])
|
||||
|
||||
@@ -131,7 +129,7 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
tags: tags.length > 0 ? tags : undefined
|
||||
}
|
||||
|
||||
onSubmit(data, saveToLibrary)
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -367,20 +365,6 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Save to Library Checkbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="saveToLibrary"
|
||||
checked={saveToLibrary}
|
||||
onChange={(e) => setSaveToLibrary(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label htmlFor="saveToLibrary" className="text-sm">
|
||||
Save to My Step Library for reuse
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
|
||||
@@ -123,7 +123,9 @@ export function TreeNavigationPage() {
|
||||
return customSteps.find(cs => cs.id === nodeId) || null
|
||||
}
|
||||
|
||||
// Get descendant nodes (grandchildren) from a decision node's options
|
||||
// Get descendant nodes two levels deep (grandchildren) from a decision node's options.
|
||||
// This skips the immediate children (which often mirror the option labels) and shows
|
||||
// the actual next-level nodes the user would encounter further down each path.
|
||||
const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => {
|
||||
const decisionNode = findNode(decisionNodeId, tree?.tree_structure)
|
||||
if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) {
|
||||
@@ -134,15 +136,35 @@ export function TreeNavigationPage() {
|
||||
|
||||
for (const option of decisionNode.options) {
|
||||
if (!option.next_node_id) continue
|
||||
const nextNode = findNode(option.next_node_id, tree?.tree_structure)
|
||||
if (!nextNode) continue
|
||||
const childNode = findNode(option.next_node_id, tree?.tree_structure)
|
||||
if (!childNode) continue
|
||||
|
||||
descendants.push({
|
||||
id: nextNode.id,
|
||||
label: nextNode.question || nextNode.title || 'Untitled',
|
||||
type: nextNode.type,
|
||||
parentOptionLabel: option.label
|
||||
})
|
||||
// Go one level deeper: get the grandchildren
|
||||
if (childNode.type === 'decision' && childNode.options) {
|
||||
for (const childOption of childNode.options) {
|
||||
if (!childOption.next_node_id) continue
|
||||
const grandchild = findNode(childOption.next_node_id, tree?.tree_structure)
|
||||
if (!grandchild) continue
|
||||
|
||||
descendants.push({
|
||||
id: grandchild.id,
|
||||
label: grandchild.question || grandchild.title || 'Untitled',
|
||||
type: grandchild.type,
|
||||
parentOptionLabel: `${option.label} \u2192 ${childOption.label}`
|
||||
})
|
||||
}
|
||||
} else if (childNode.type === 'action' && childNode.next_node_id) {
|
||||
const grandchild = findNode(childNode.next_node_id, tree?.tree_structure)
|
||||
if (grandchild) {
|
||||
descendants.push({
|
||||
id: grandchild.id,
|
||||
label: grandchild.question || grandchild.title || 'Untitled',
|
||||
type: grandchild.type,
|
||||
parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}`
|
||||
})
|
||||
}
|
||||
}
|
||||
// Solution children have no further descendants - skip them
|
||||
}
|
||||
|
||||
return descendants
|
||||
@@ -262,6 +284,13 @@ export function TreeNavigationPage() {
|
||||
setCurrentNodeId(newPath[newPath.length - 1])
|
||||
}
|
||||
|
||||
// Navigate back to a previously-created custom step from the decision node
|
||||
const handleNavigateToCustomStep = (customStep: CustomStep) => {
|
||||
const newPath = [...pathTaken, customStep.id]
|
||||
setPathTaken(newPath)
|
||||
setCurrentNodeId(customStep.id)
|
||||
}
|
||||
|
||||
// Called when CustomStepModal submits - show action modal instead of inserting directly
|
||||
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
|
||||
setPendingStep(step)
|
||||
@@ -616,7 +645,9 @@ export function TreeNavigationPage() {
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm">
|
||||
{pathTaken.map((nodeId, index) => {
|
||||
const node = findNode(nodeId)
|
||||
const node = findNode(nodeId, tree?.tree_structure)
|
||||
const customStep = findCustomStep(nodeId)
|
||||
const label = node?.question || node?.title || customStep?.step_data.title || nodeId
|
||||
return (
|
||||
<span key={nodeId} className="flex items-center gap-2 whitespace-nowrap">
|
||||
{index > 0 && <span className="text-muted-foreground">→</span>}
|
||||
@@ -627,8 +658,7 @@ export function TreeNavigationPage() {
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{node?.question?.slice(0, 30) || node?.title?.slice(0, 30) || nodeId}
|
||||
{((node?.question?.length || 0) > 30 || (node?.title?.length || 0) > 30) && '...'}
|
||||
{label.length > 30 ? `${label.slice(0, 30)}...` : label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
@@ -668,6 +698,35 @@ export function TreeNavigationPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Previously-created custom steps at this node */}
|
||||
{customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Your Custom Steps
|
||||
</p>
|
||||
{customSteps
|
||||
.filter(cs => cs.inserted_after_node_id === currentNodeId)
|
||||
.map(cs => (
|
||||
<button
|
||||
type="button"
|
||||
key={cs.id}
|
||||
onClick={() => handleNavigateToCustomStep(cs)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-purple-300 bg-purple-50 p-3 text-left transition-colors',
|
||||
'hover:border-purple-500 hover:bg-purple-100',
|
||||
'dark:border-purple-700 dark:bg-purple-900/20 dark:hover:border-purple-500 dark:hover:bg-purple-900/40',
|
||||
'flex items-center gap-3'
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-100">
|
||||
Custom
|
||||
</span>
|
||||
<span>{cs.step_data.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Custom Step Button */}
|
||||
<button
|
||||
onClick={() => setShowCustomStepModal(true)}
|
||||
|
||||
Reference in New Issue
Block a user