FeaturesDynamic SectionsPhase 1: Section Management

Phase 1: Foundation - Backend Models and API

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Create Section and NavLink backend models, database collections, and CRUD API endpoints.

Architecture: Follow existing patterns from Story/Project models and handlers.

Tech Stack: FastAPI, Pydantic, MongoDB (motor), pytest

Prerequisite: Ensure make dev-backend works with local MongoDB via docker-compose.


Task 0: Verify Local Development Environment

Files:

  • Check: docker-compose.yml
  • Check: .env

Step 1: Start MongoDB container

Run: docker-compose up -d mongo Expected: Container starts successfully

Step 2: Verify MongoDB is accessible

Run: docker-compose ps Expected: mongo container is “Up”

Step 3: Test backend starts

Run: make dev-backend Expected: Uvicorn starts on port 5001, connects to MongoDB

Step 4: Commit (if any fixes needed)

Only if changes were required to make dev-backend work.


Task 1: Section Model

Files:

  • Create: backend/models/section.py
  • Modify: backend/models/__init__.py
  • Test: backend/tests/test_models.py

Step 1: Write the failing test

Add to backend/tests/test_models.py:

class TestSectionBase:
    """Tests for SectionBase model."""
 
    def test_valid_section_base(self):
        """Test creating a valid SectionBase."""
        section = SectionBase(
            title="Blog",
            slug="blog",
            display_type="feed",
            content_type="story",
            nav_visibility="main",
            sort_order=0,
        )
        assert section.title == "Blog"
        assert section.slug == "blog"
        assert section.display_type == "feed"
        assert section.content_type == "story"
        assert section.nav_visibility == "main"
        assert section.sort_order == 0
        assert section.parent_id is None
        assert section.is_published is True
 
    def test_section_base_with_parent(self):
        """Test creating a section with parent_id."""
        section = SectionBase(
            title="Photography",
            slug="photography",
            display_type="gallery",
            content_type="image",
            nav_visibility="main",
            sort_order=1,
            parent_id="507f1f77bcf86cd799439011",
        )
        assert section.parent_id == "507f1f77bcf86cd799439011"
 
    def test_section_base_title_required(self):
        """Test that title is required."""
        with pytest.raises(ValidationError):
            SectionBase(
                slug="blog",
                display_type="feed",
                content_type="story",
                nav_visibility="main",
                sort_order=0,
            )
 
    def test_section_base_invalid_display_type(self):
        """Test that invalid display_type is rejected."""
        with pytest.raises(ValidationError):
            SectionBase(
                title="Blog",
                slug="blog",
                display_type="invalid",
                content_type="story",
                nav_visibility="main",
                sort_order=0,
            )
 
    def test_section_base_invalid_nav_visibility(self):
        """Test that invalid nav_visibility is rejected."""
        with pytest.raises(ValidationError):
            SectionBase(
                title="Blog",
                slug="blog",
                display_type="feed",
                content_type="story",
                nav_visibility="invalid",
                sort_order=0,
            )
 
 
class TestSectionCreate:
    """Tests for SectionCreate model."""
 
    def test_valid_section_create(self):
        """Test creating a valid SectionCreate."""
        section = SectionCreate(
            title="Projects",
            display_type="card-grid",
            content_type="project",
            nav_visibility="main",
            sort_order=2,
        )
        assert section.title == "Projects"
        assert section.slug is None  # Generated by handler
 
 
class TestSectionUpdate:
    """Tests for SectionUpdate model."""
 
    def test_section_update_partial(self):
        """Test updating with partial data."""
        update = SectionUpdate(title="New Title")
        assert update.title == "New Title"
        assert update.display_type is None
        assert update.is_published is None
 
    def test_section_update_empty_valid(self):
        """Test that empty update is valid."""
        update = SectionUpdate()
        assert update.title is None
 
 
class TestSectionResponse:
    """Tests for SectionResponse model."""
 
    def test_valid_section_response(self):
        """Test creating a valid SectionResponse."""
        now = datetime.now(timezone.utc)
        section = SectionResponse(
            id="507f1f77bcf86cd799439011",
            title="Blog",
            slug="blog",
            display_type="feed",
            content_type="story",
            nav_visibility="main",
            sort_order=0,
            is_published=True,
            createdDate=now,
            updatedDate=now,
        )
        assert section.id == "507f1f77bcf86cd799439011"
        assert section.createdDate.tzinfo is not None

Step 2: Run test to verify it fails

Run: make test Expected: FAIL with “cannot import name ‘SectionBase’”

Step 3: Write the Section model

Create backend/models/section.py:

from datetime import datetime, timezone
from typing import Literal
 
from pydantic import BaseModel, ConfigDict, Field, field_validator
 
DisplayType = Literal["feed", "card-grid", "static-page", "gallery"]
ContentType = Literal["story", "project", "page", "image"]
NavVisibility = Literal["main", "secondary", "hidden"]
 
 
class SectionBase(BaseModel):
    """Base model for section data."""
 
    title: str = Field(..., min_length=1, max_length=200)
    slug: str | None = Field(None, min_length=1, max_length=200)
    parent_id: str | None = Field(None)
    display_type: DisplayType
    content_type: ContentType
    nav_visibility: NavVisibility = "main"
    sort_order: int = Field(0, ge=0)
    is_published: bool = True
 
 
class SectionCreate(BaseModel):
    """Model for creating a new section."""
 
    title: str = Field(..., min_length=1, max_length=200)
    parent_id: str | None = Field(None)
    display_type: DisplayType
    content_type: ContentType
    nav_visibility: NavVisibility = "main"
    sort_order: int = Field(0, ge=0)
    is_published: bool = True
 
 
class SectionUpdate(BaseModel):
    """Model for updating a section (all fields optional)."""
 
    title: str | None = Field(None, min_length=1, max_length=200)
    parent_id: str | None = Field(None)
    display_type: DisplayType | None = None
    content_type: ContentType | None = None
    nav_visibility: NavVisibility | None = None
    sort_order: int | None = Field(None, ge=0)
    is_published: bool | None = None
 
 
class SectionResponse(SectionBase):
    """Model for section API responses."""
 
    id: str
    slug: str
    createdDate: datetime
    updatedDate: datetime
    user_id: str | None = None
 
    @field_validator("createdDate", "updatedDate")
    def ensure_utc(cls, value: datetime | None) -> datetime | None:
        """Ensure datetime values are in UTC timezone."""
        if value is None:
            return None
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value.astimezone(timezone.utc)
 
    model_config = ConfigDict(from_attributes=True)

Step 4: Add import to test file

Add to top of backend/tests/test_models.py:

from models.section import (
    SectionBase,
    SectionCreate,
    SectionResponse,
    SectionUpdate,
)

Step 5: Run test to verify it passes

Run: make test Expected: All section model tests PASS

Step 6: Commit

git add backend/models/section.py backend/tests/test_models.py
git commit -m "feat(backend): add Section model with validation
 
- SectionBase, SectionCreate, SectionUpdate, SectionResponse
- DisplayType: feed, card-grid, static-page, gallery
- ContentType: story, project, page, image
- NavVisibility: main, secondary, hidden
- Tests for all model variants
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Files:

  • Create: backend/models/navlink.py
  • Modify: backend/tests/test_models.py

Step 1: Write the failing test

Add to backend/tests/test_models.py:

class TestNavLinkBase:
    """Tests for NavLinkBase model."""
 
    def test_valid_navlink_base(self):
        """Test creating a valid NavLinkBase."""
        link = NavLinkBase(
            label="GitHub",
            url="https://github.com/user",
            sort_order=0,
        )
        assert link.label == "GitHub"
        assert link.url == "https://github.com/user"
        assert link.sort_order == 0
        assert link.is_published is True
 
    def test_navlink_internal_url(self):
        """Test creating a navlink with internal path."""
        link = NavLinkBase(
            label="About",
            url="/about",
            sort_order=1,
        )
        assert link.url == "/about"
 
    def test_navlink_label_required(self):
        """Test that label is required."""
        with pytest.raises(ValidationError):
            NavLinkBase(url="/about", sort_order=0)
 
 
class TestNavLinkCreate:
    """Tests for NavLinkCreate model."""
 
    def test_valid_navlink_create(self):
        """Test creating a valid NavLinkCreate."""
        link = NavLinkCreate(
            label="Contact",
            url="/contact",
            sort_order=2,
        )
        assert link.label == "Contact"
 
 
class TestNavLinkUpdate:
    """Tests for NavLinkUpdate model."""
 
    def test_navlink_update_partial(self):
        """Test updating with partial data."""
        update = NavLinkUpdate(label="New Label")
        assert update.label == "New Label"
        assert update.url is None
 
 
class TestNavLinkResponse:
    """Tests for NavLinkResponse model."""
 
    def test_valid_navlink_response(self):
        """Test creating a valid NavLinkResponse."""
        now = datetime.now(timezone.utc)
        link = NavLinkResponse(
            id="507f1f77bcf86cd799439011",
            label="GitHub",
            url="https://github.com/user",
            sort_order=0,
            is_published=True,
            createdDate=now,
            updatedDate=now,
        )
        assert link.id == "507f1f77bcf86cd799439011"

Step 2: Run test to verify it fails

Run: make test Expected: FAIL with “cannot import name ‘NavLinkBase’”

Step 3: Write the NavLink model

Create backend/models/navlink.py:

from datetime import datetime, timezone
 
from pydantic import BaseModel, ConfigDict, Field, field_validator
 
 
class NavLinkBase(BaseModel):
    """Base model for navigation link data."""
 
    label: str = Field(..., min_length=1, max_length=100)
    url: str = Field(..., min_length=1, max_length=500)
    sort_order: int = Field(0, ge=0)
    is_published: bool = True
 
 
class NavLinkCreate(NavLinkBase):
    """Model for creating a new navigation link."""
 
    pass
 
 
class NavLinkUpdate(BaseModel):
    """Model for updating a navigation link (all fields optional)."""
 
    label: str | None = Field(None, min_length=1, max_length=100)
    url: str | None = Field(None, min_length=1, max_length=500)
    sort_order: int | None = Field(None, ge=0)
    is_published: bool | None = None
 
 
class NavLinkResponse(NavLinkBase):
    """Model for navigation link API responses."""
 
    id: str
    createdDate: datetime
    updatedDate: datetime
 
    @field_validator("createdDate", "updatedDate")
    def ensure_utc(cls, value: datetime | None) -> datetime | None:
        """Ensure datetime values are in UTC timezone."""
        if value is None:
            return None
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value.astimezone(timezone.utc)
 
    model_config = ConfigDict(from_attributes=True)

Step 4: Add import to test file

Add to top of backend/tests/test_models.py:

from models.navlink import (
    NavLinkBase,
    NavLinkCreate,
    NavLinkResponse,
    NavLinkUpdate,
)

Step 5: Run test to verify it passes

Run: make test Expected: All navlink model tests PASS

Step 6: Commit

git add backend/models/navlink.py backend/tests/test_models.py
git commit -m "feat(backend): add NavLink model for hamburger menu
 
- NavLinkBase, NavLinkCreate, NavLinkUpdate, NavLinkResponse
- Supports internal paths and external URLs
- Tests for all model variants
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 3: Database Collections

Files:

  • Modify: backend/database.py

Step 1: Add collection getters

Add to backend/database.py after line 122 (after get_reactions_collection):

async def get_sections_collection() -> AsyncIOMotorCollection:
    db = await get_db()
    return db["sections"]
 
 
async def get_navlinks_collection() -> AsyncIOMotorCollection:
    db = await get_db()
    return db["navlinks"]

Step 2: Add indexes in ensure_indexes function

Add to backend/database.py inside ensure_indexes() function, after reactions indexes (around line 220):

    # Sections indexes
    sections = db["sections"]
    if not await safe_create_index(sections, "slug", unique=True):
        failed_indexes.append("sections.slug")
    await safe_create_index(sections, "parent_id")
    await safe_create_index(sections, [("nav_visibility", 1), ("sort_order", 1)])
    await safe_create_index(sections, [("is_published", 1), ("sort_order", 1)])
 
    # NavLinks indexes
    navlinks = db["navlinks"]
    await safe_create_index(navlinks, [("sort_order", 1)])
    await safe_create_index(navlinks, "is_published")

Step 3: Run backend to verify indexes are created

Run: make dev-backend Expected: Server starts, no index creation errors in logs

Step 4: Commit

git add backend/database.py
git commit -m "feat(backend): add sections and navlinks collections
 
- Collection getters for sections and navlinks
- Indexes for slug (unique), parent_id, nav_visibility, sort_order
- NavLinks indexes for sort_order and is_published
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 4: Sections Handler - GET endpoints

Files:

  • Create: backend/handlers/sections.py
  • Modify: backend/app.py
  • Create: backend/tests/test_sections_api.py

Step 1: Write the failing test for GET /sections

Create backend/tests/test_sections_api.py:

from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
 
import pytest
import pytest_asyncio
from bson import ObjectId
from httpx import ASGITransport, AsyncClient
 
from app import app as test_app
from database import get_sections_collection
 
 
@pytest.fixture
def mock_sections_collection():
    """Mock collection for sections testing."""
    mock = MagicMock()
    mock.find_one = AsyncMock()
    mock.count_documents = AsyncMock()
    mock.insert_one = AsyncMock()
    mock.update_one = AsyncMock()
    mock.find = MagicMock()
    return mock
 
 
@pytest.fixture
def override_sections_database(mock_sections_collection):
    """Override the sections collection to use mocks."""
 
    async def get_mock_sections_collection():
        return mock_sections_collection
 
    test_app.dependency_overrides[get_sections_collection] = get_mock_sections_collection
    yield mock_sections_collection
    test_app.dependency_overrides.pop(get_sections_collection, None)
 
 
@pytest_asyncio.fixture
async def sections_async_client(override_sections_database):
    """Async test client for sections tests."""
    async with AsyncClient(
        transport=ASGITransport(app=test_app), base_url="http://test"
    ) as ac:
        yield ac
 
 
@pytest.fixture
def sample_section_doc():
    """Sample section document as stored in MongoDB."""
    fixed_datetime = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
    return {
        "_id": ObjectId("507f1f77bcf86cd799439011"),
        "title": "Blog",
        "slug": "blog",
        "parent_id": None,
        "display_type": "feed",
        "content_type": "story",
        "nav_visibility": "main",
        "sort_order": 0,
        "is_published": True,
        "createdDate": fixed_datetime,
        "updatedDate": fixed_datetime,
        "user_id": "user123",
    }
 
 
class TestGetSections:
    """Tests for GET /sections endpoint."""
 
    @pytest.mark.asyncio
    async def test_get_sections_success(
        self, sections_async_client, mock_sections_collection, sample_section_doc
    ):
        """Test successful retrieval of sections."""
        mock_cursor = MagicMock()
        mock_cursor.to_list = AsyncMock(return_value=[sample_section_doc])
        mock_sections_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
            mock_cursor
        )
        mock_sections_collection.count_documents = AsyncMock(return_value=1)
 
        response = await sections_async_client.get("/sections")
 
        assert response.status_code == 200
        data = response.json()
        assert data["total"] == 1
        assert len(data["items"]) == 1
        assert data["items"][0]["title"] == "Blog"
        assert data["items"][0]["slug"] == "blog"
 
    @pytest.mark.asyncio
    async def test_get_sections_empty(
        self, sections_async_client, mock_sections_collection
    ):
        """Test getting sections when none exist."""
        mock_cursor = MagicMock()
        mock_cursor.to_list = AsyncMock(return_value=[])
        mock_sections_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
            mock_cursor
        )
        mock_sections_collection.count_documents = AsyncMock(return_value=0)
 
        response = await sections_async_client.get("/sections")
 
        assert response.status_code == 200
        data = response.json()
        assert data["total"] == 0
        assert len(data["items"]) == 0
 
    @pytest.mark.asyncio
    async def test_get_sections_pagination(
        self, sections_async_client, mock_sections_collection, sample_section_doc
    ):
        """Test sections pagination."""
        mock_cursor = MagicMock()
        mock_cursor.to_list = AsyncMock(return_value=[sample_section_doc])
        mock_sections_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
            mock_cursor
        )
        mock_sections_collection.count_documents = AsyncMock(return_value=10)
 
        response = await sections_async_client.get("/sections?limit=5&offset=5")
 
        assert response.status_code == 200
        data = response.json()
        assert data["limit"] == 5
        assert data["offset"] == 5

Step 2: Run test to verify it fails

Run: make test Expected: FAIL - no route for /sections

Step 3: Create sections handler with GET endpoints

Create backend/handlers/sections.py:

from datetime import datetime, timezone
 
from bson import ObjectId
from database import get_sections_collection
from decorators.auth import check_write_permission, requires_auth
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from glogger import logger
from models.section import SectionCreate, SectionResponse, SectionUpdate
from models.user import UserInfo
from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import ValidationError
from utils import find_many_and_convert, find_one_and_convert, generate_unique_slug
 
router = APIRouter()
 
 
@router.get("/sections")
async def get_sections(
    request: Request,
    response: Response,
    limit: int = Query(10, ge=1, le=50),
    offset: int = Query(0, ge=0),
    include_unpublished: bool = Query(False),
    parent_id: str | None = Query(None),
    nav_visibility: str | None = Query(None),
    collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
    """Get all sections with pagination and filtering."""
    try:
        query: dict = {"deleted": {"$ne": True}}
 
        if not include_unpublished:
            query["is_published"] = True
 
        if parent_id is not None:
            query["parent_id"] = parent_id if parent_id else None
 
        if nav_visibility:
            query["nav_visibility"] = nav_visibility
 
        logger.info_with_context(
            "Fetching sections",
            {
                "limit": limit,
                "offset": offset,
                "include_unpublished": include_unpublished,
                "parent_id": parent_id,
                "nav_visibility": nav_visibility,
            },
        )
 
        total = await collection.count_documents(query)
        sort = [("sort_order", 1), ("createdDate", -1)]
 
        sections = await find_many_and_convert(
            collection,
            query,
            SectionResponse,
            sort=sort,
            limit=limit,
            skip=offset,
        )
 
        logger.info_with_context(
            "Successfully fetched sections",
            {"count": len(sections), "total": total},
        )
 
        return {"items": sections, "total": total, "limit": limit, "offset": offset}
    except Exception as e:
        logger.exception_with_context(
            "Error fetching sections",
            {"error": str(e)},
        )
        logger.log_request_response(request, error=e)
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Error fetching sections",
                "error_type": type(e).__name__,
            },
        )
 
 
@router.get("/sections/{section_id}", response_model=SectionResponse)
async def get_section(
    request: Request,
    section_id: str,
    collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
    """Get a section by ID."""
    try:
        if not ObjectId.is_valid(section_id):
            raise HTTPException(status_code=400, detail="Invalid section ID format")
 
        logger.info_with_context("Fetching section by ID", {"section_id": section_id})
 
        section = await find_one_and_convert(
            collection,
            {"_id": ObjectId(section_id), "deleted": {"$ne": True}},
            SectionResponse,
        )
 
        if not section:
            raise HTTPException(status_code=404, detail="Section not found")
 
        return section
    except HTTPException:
        raise
    except Exception as e:
        logger.exception_with_context(
            "Error fetching section",
            {"section_id": section_id, "error": str(e)},
        )
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Error fetching section",
                "error_type": type(e).__name__,
            },
        )
 
 
@router.get("/sections/by-slug/{slug}", response_model=SectionResponse)
async def get_section_by_slug(
    request: Request,
    slug: str,
    collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
    """Get a section by slug."""
    try:
        logger.info_with_context("Fetching section by slug", {"slug": slug})
 
        section = await find_one_and_convert(
            collection,
            {"slug": slug, "deleted": {"$ne": True}},
            SectionResponse,
        )
 
        if not section:
            raise HTTPException(status_code=404, detail="Section not found")
 
        return section
    except HTTPException:
        raise
    except Exception as e:
        logger.exception_with_context(
            "Error fetching section by slug",
            {"slug": slug, "error": str(e)},
        )
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Error fetching section",
                "error_type": type(e).__name__,
            },
        )

Step 4: Register router in app.py

Add to backend/app.py imports (around line 12):

from handlers.sections import router as sections_router

Add to route registration (around line 229):

app.include_router(sections_router)

Step 5: Run test to verify it passes

Run: make test Expected: GET /sections tests PASS

Step 6: Commit

git add backend/handlers/sections.py backend/app.py backend/tests/test_sections_api.py
git commit -m "feat(backend): add sections GET endpoints
 
- GET /sections with pagination, filtering by parent_id and nav_visibility
- GET /sections/{section_id} by ID
- GET /sections/by-slug/{slug} by slug
- Tests for list and pagination
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 5: Sections Handler - POST endpoint

Files:

  • Modify: backend/handlers/sections.py
  • Modify: backend/tests/test_sections_api.py
  • Modify: backend/tests/conftest.py

Step 1: Write the failing test for POST /sections

Add to backend/tests/test_sections_api.py:

class TestCreateSection:
    """Tests for POST /sections endpoint."""
 
    @pytest.mark.asyncio
    async def test_create_section_success(
        self,
        sections_async_client,
        mock_sections_collection,
        sample_section_doc,
        mock_auth_valid,
    ):
        """Test successful section creation."""
        mock_sections_collection.insert_one = AsyncMock(
            return_value=MagicMock(inserted_id=sample_section_doc["_id"])
        )
        mock_sections_collection.find_one = AsyncMock(return_value=sample_section_doc)
 
        response = await sections_async_client.post(
            "/sections",
            json={
                "title": "Blog",
                "display_type": "feed",
                "content_type": "story",
                "nav_visibility": "main",
                "sort_order": 0,
            },
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 201
        data = response.json()
        assert data["title"] == "Blog"
        assert data["slug"] == "blog"
 
    @pytest.mark.asyncio
    async def test_create_section_unauthorized(
        self, sections_async_client, mock_sections_collection
    ):
        """Test section creation without auth."""
        response = await sections_async_client.post(
            "/sections",
            json={
                "title": "Blog",
                "display_type": "feed",
                "content_type": "story",
            },
        )
 
        assert response.status_code == 401
 
    @pytest.mark.asyncio
    async def test_create_section_invalid_display_type(
        self,
        sections_async_client,
        mock_sections_collection,
        mock_auth_valid,
    ):
        """Test section creation with invalid display_type."""
        response = await sections_async_client.post(
            "/sections",
            json={
                "title": "Blog",
                "display_type": "invalid",
                "content_type": "story",
            },
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 422

Step 2: Run test to verify it fails

Run: make test Expected: FAIL - POST endpoint not implemented

Step 3: Add POST endpoint to sections handler

Add to backend/handlers/sections.py:

@router.post("/sections", response_model=SectionResponse, status_code=201)
@requires_auth
async def create_section(
    request: Request,
    section: SectionCreate,
    collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
    """Create a new section."""
    try:
        user: UserInfo = request.state.user
 
        logger.info_with_context(
            "Creating new section",
            {
                "title": section.title,
                "display_type": section.display_type,
                "content_type": section.content_type,
                "user_id": user.id,
            },
        )
 
        current_time = datetime.now(timezone.utc)
        slug = await generate_unique_slug(collection, section.title)
 
        document = {
            **section.model_dump(),
            "slug": slug,
            "createdDate": current_time,
            "updatedDate": current_time,
            "user_id": user.id,
        }
 
        result = await collection.insert_one(document)
        section_id = str(result.inserted_id)
 
        logger.info_with_context(
            "Inserted section document",
            {"section_id": section_id, "slug": slug},
        )
 
        created_section = await find_one_and_convert(
            collection, {"_id": result.inserted_id}, SectionResponse
        )
 
        if not created_section:
            raise HTTPException(
                status_code=500, detail="Failed to retrieve created section"
            )
 
        logger.info_with_context(
            "Section created successfully",
            {"section_id": section_id, "slug": slug},
        )
 
        return created_section
    except ValidationError as e:
        error_details = e.errors() if hasattr(e, "errors") else str(e)
        logger.error_with_context(
            "Section validation error",
            {"error": str(e), "details": error_details},
        )
        raise HTTPException(
            status_code=400,
            detail={
                "message": "Validation error",
                "error_type": "ValidationError",
                "error_details": error_details,
            },
        )
    except Exception as e:
        logger.exception_with_context(
            "Error creating section",
            {"error": str(e)},
        )
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Error creating section",
                "error_type": type(e).__name__,
            },
        )

Step 4: Run test to verify it passes

Run: make test Expected: POST /sections tests PASS

Step 5: Commit

git add backend/handlers/sections.py backend/tests/test_sections_api.py
git commit -m "feat(backend): add sections POST endpoint
 
- POST /sections creates new section with auth
- Auto-generates unique slug from title
- Validates display_type and content_type
- Tests for success, auth, and validation
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 6: Sections Handler - PUT and DELETE endpoints

Files:

  • Modify: backend/handlers/sections.py
  • Modify: backend/tests/test_sections_api.py

Step 1: Write the failing tests

Add to backend/tests/test_sections_api.py:

class TestUpdateSection:
    """Tests for PUT /sections/{section_id} endpoint."""
 
    @pytest.mark.asyncio
    async def test_update_section_success(
        self,
        sections_async_client,
        mock_sections_collection,
        sample_section_doc,
        mock_auth_valid,
    ):
        """Test successful section update."""
        mock_sections_collection.find_one = AsyncMock(return_value=sample_section_doc)
        mock_sections_collection.update_one = AsyncMock(
            return_value=MagicMock(modified_count=1)
        )
 
        response = await sections_async_client.put(
            "/sections/507f1f77bcf86cd799439011",
            json={"title": "Updated Blog"},
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 200
        data = response.json()
        assert data["title"] == "Blog"  # Mock returns original
 
    @pytest.mark.asyncio
    async def test_update_section_not_found(
        self,
        sections_async_client,
        mock_sections_collection,
        mock_auth_valid,
    ):
        """Test updating non-existent section."""
        mock_sections_collection.find_one = AsyncMock(return_value=None)
 
        response = await sections_async_client.put(
            "/sections/507f1f77bcf86cd799439011",
            json={"title": "Updated Blog"},
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 404
 
 
class TestDeleteSection:
    """Tests for DELETE /sections/{section_id} endpoint."""
 
    @pytest.mark.asyncio
    async def test_delete_section_success(
        self,
        sections_async_client,
        mock_sections_collection,
        sample_section_doc,
        mock_auth_valid,
    ):
        """Test successful section deletion."""
        mock_sections_collection.find_one = AsyncMock(return_value=sample_section_doc)
        mock_sections_collection.update_one = AsyncMock(
            return_value=MagicMock(modified_count=1)
        )
 
        response = await sections_async_client.delete(
            "/sections/507f1f77bcf86cd799439011",
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 204
 
    @pytest.mark.asyncio
    async def test_delete_section_not_found(
        self,
        sections_async_client,
        mock_sections_collection,
        mock_auth_valid,
    ):
        """Test deleting non-existent section."""
        mock_sections_collection.find_one = AsyncMock(return_value=None)
 
        response = await sections_async_client.delete(
            "/sections/507f1f77bcf86cd799439011",
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 404

Step 2: Run test to verify it fails

Run: make test Expected: FAIL - PUT and DELETE endpoints not implemented

Step 3: Add PUT and DELETE endpoints

Add to backend/handlers/sections.py:

@router.put("/sections/{section_id}", response_model=SectionResponse)
@requires_auth
async def update_section(
    request: Request,
    section_id: str,
    section: SectionUpdate,
    collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
    """Update a section."""
    try:
        user: UserInfo = request.state.user
 
        if not ObjectId.is_valid(section_id):
            raise HTTPException(status_code=400, detail="Invalid section ID format")
 
        logger.info_with_context(
            "Updating section",
            {"section_id": section_id, "user_id": user.id},
        )
 
        existing_section = await find_one_and_convert(
            collection,
            {"_id": ObjectId(section_id), "deleted": {"$ne": True}},
            SectionResponse,
        )
 
        if not existing_section:
            raise HTTPException(status_code=404, detail="Section not found")
 
        if not check_write_permission(user, existing_section.user_id):
            raise HTTPException(
                status_code=403,
                detail="You don't have permission to edit this section",
            )
 
        current_time = datetime.now(timezone.utc)
        update_data = {k: v for k, v in section.model_dump().items() if v is not None}
        update_data["updatedDate"] = current_time
 
        if section.title and existing_section.title != section.title:
            update_data["slug"] = await generate_unique_slug(
                collection, section.title, ObjectId(section_id)
            )
 
        result = await collection.update_one(
            {"_id": ObjectId(section_id)}, {"$set": update_data}
        )
 
        if result.modified_count == 0:
            raise HTTPException(status_code=500, detail="Failed to update section")
 
        updated_section = await find_one_and_convert(
            collection, {"_id": ObjectId(section_id)}, SectionResponse
        )
 
        if not updated_section:
            raise HTTPException(
                status_code=500, detail="Failed to retrieve updated section"
            )
 
        logger.info_with_context(
            "Section updated successfully",
            {"section_id": section_id},
        )
 
        return updated_section
    except HTTPException:
        raise
    except ValidationError as e:
        logger.error_with_context(
            "Section validation error",
            {"error": str(e)},
        )
        raise HTTPException(
            status_code=400,
            detail={
                "message": "Validation error",
                "error_type": "ValidationError",
            },
        )
    except Exception as e:
        logger.exception_with_context(
            "Error updating section",
            {"section_id": section_id, "error": str(e)},
        )
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Error updating section",
                "error_type": type(e).__name__,
            },
        )
 
 
@router.delete("/sections/{section_id}", status_code=204)
@requires_auth
async def delete_section(
    request: Request,
    section_id: str,
    collection: AsyncIOMotorCollection = Depends(get_sections_collection),
):
    """Soft delete a section."""
    try:
        user: UserInfo = request.state.user
 
        if not ObjectId.is_valid(section_id):
            raise HTTPException(status_code=400, detail="Invalid section ID format")
 
        logger.info_with_context(
            "Soft deleting section",
            {"section_id": section_id, "user_id": user.id},
        )
 
        existing_section = await find_one_and_convert(
            collection,
            {"_id": ObjectId(section_id), "deleted": {"$ne": True}},
            SectionResponse,
        )
 
        if not existing_section:
            raise HTTPException(status_code=404, detail="Section not found")
 
        if not check_write_permission(user, existing_section.user_id):
            raise HTTPException(
                status_code=403,
                detail="You don't have permission to delete this section",
            )
 
        result = await collection.update_one(
            {"_id": ObjectId(section_id)}, {"$set": {"deleted": True}}
        )
 
        if result.modified_count == 0:
            raise HTTPException(status_code=500, detail="Failed to delete section")
 
        logger.info_with_context(
            "Section soft deleted successfully",
            {"section_id": section_id},
        )
    except HTTPException:
        raise
    except Exception as e:
        logger.exception_with_context(
            "Error deleting section",
            {"section_id": section_id, "error": str(e)},
        )
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Error deleting section",
                "error_type": type(e).__name__,
            },
        )

Step 4: Run test to verify it passes

Run: make test Expected: PUT and DELETE tests PASS

Step 5: Commit

git add backend/handlers/sections.py backend/tests/test_sections_api.py
git commit -m "feat(backend): add sections PUT and DELETE endpoints
 
- PUT /sections/{id} updates section with auth and permission check
- DELETE /sections/{id} soft deletes with auth and permission check
- Regenerates slug if title changes
- Tests for success and not found cases
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Files:

  • Create: backend/handlers/navlinks.py
  • Modify: backend/app.py
  • Create: backend/tests/test_navlinks_api.py

Step 1: Write the failing tests

Create backend/tests/test_navlinks_api.py:

from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
 
import pytest
import pytest_asyncio
from bson import ObjectId
from httpx import ASGITransport, AsyncClient
 
from app import app as test_app
from database import get_navlinks_collection
 
 
@pytest.fixture
def mock_navlinks_collection():
    """Mock collection for navlinks testing."""
    mock = MagicMock()
    mock.find_one = AsyncMock()
    mock.count_documents = AsyncMock()
    mock.insert_one = AsyncMock()
    mock.update_one = AsyncMock()
    mock.find = MagicMock()
    return mock
 
 
@pytest.fixture
def override_navlinks_database(mock_navlinks_collection):
    """Override the navlinks collection to use mocks."""
 
    async def get_mock_navlinks_collection():
        return mock_navlinks_collection
 
    test_app.dependency_overrides[get_navlinks_collection] = get_mock_navlinks_collection
    yield mock_navlinks_collection
    test_app.dependency_overrides.pop(get_navlinks_collection, None)
 
 
@pytest_asyncio.fixture
async def navlinks_async_client(override_navlinks_database):
    """Async test client for navlinks tests."""
    async with AsyncClient(
        transport=ASGITransport(app=test_app), base_url="http://test"
    ) as ac:
        yield ac
 
 
@pytest.fixture
def sample_navlink_doc():
    """Sample navlink document as stored in MongoDB."""
    fixed_datetime = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
    return {
        "_id": ObjectId("507f1f77bcf86cd799439011"),
        "label": "GitHub",
        "url": "https://github.com/user",
        "sort_order": 0,
        "is_published": True,
        "createdDate": fixed_datetime,
        "updatedDate": fixed_datetime,
    }
 
 
class TestGetNavLinks:
    """Tests for GET /navlinks endpoint."""
 
    @pytest.mark.asyncio
    async def test_get_navlinks_success(
        self, navlinks_async_client, mock_navlinks_collection, sample_navlink_doc
    ):
        """Test successful retrieval of navlinks."""
        mock_cursor = MagicMock()
        mock_cursor.to_list = AsyncMock(return_value=[sample_navlink_doc])
        mock_navlinks_collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = (
            mock_cursor
        )
        mock_navlinks_collection.count_documents = AsyncMock(return_value=1)
 
        response = await navlinks_async_client.get("/navlinks")
 
        assert response.status_code == 200
        data = response.json()
        assert data["total"] == 1
        assert data["items"][0]["label"] == "GitHub"
 
 
class TestCreateNavLink:
    """Tests for POST /navlinks endpoint."""
 
    @pytest.mark.asyncio
    async def test_create_navlink_success(
        self,
        navlinks_async_client,
        mock_navlinks_collection,
        sample_navlink_doc,
        mock_auth_valid,
    ):
        """Test successful navlink creation."""
        mock_navlinks_collection.insert_one = AsyncMock(
            return_value=MagicMock(inserted_id=sample_navlink_doc["_id"])
        )
        mock_navlinks_collection.find_one = AsyncMock(return_value=sample_navlink_doc)
 
        response = await navlinks_async_client.post(
            "/navlinks",
            json={
                "label": "GitHub",
                "url": "https://github.com/user",
                "sort_order": 0,
            },
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 201
        data = response.json()
        assert data["label"] == "GitHub"
 
 
class TestUpdateNavLink:
    """Tests for PUT /navlinks/{navlink_id} endpoint."""
 
    @pytest.mark.asyncio
    async def test_update_navlink_success(
        self,
        navlinks_async_client,
        mock_navlinks_collection,
        sample_navlink_doc,
        mock_auth_valid,
    ):
        """Test successful navlink update."""
        mock_navlinks_collection.find_one = AsyncMock(return_value=sample_navlink_doc)
        mock_navlinks_collection.update_one = AsyncMock(
            return_value=MagicMock(modified_count=1)
        )
 
        response = await navlinks_async_client.put(
            "/navlinks/507f1f77bcf86cd799439011",
            json={"label": "Updated Label"},
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 200
 
 
class TestDeleteNavLink:
    """Tests for DELETE /navlinks/{navlink_id} endpoint."""
 
    @pytest.mark.asyncio
    async def test_delete_navlink_success(
        self,
        navlinks_async_client,
        mock_navlinks_collection,
        sample_navlink_doc,
        mock_auth_valid,
    ):
        """Test successful navlink deletion."""
        mock_navlinks_collection.find_one = AsyncMock(return_value=sample_navlink_doc)
        mock_navlinks_collection.delete_one = AsyncMock(
            return_value=MagicMock(deleted_count=1)
        )
 
        response = await navlinks_async_client.delete(
            "/navlinks/507f1f77bcf86cd799439011",
            headers={"Authorization": "Bearer valid_token"},
        )
 
        assert response.status_code == 204

Step 2: Run test to verify it fails

Run: make test Expected: FAIL - no route for /navlinks

Step 3: Create navlinks handler

Create backend/handlers/navlinks.py:

from datetime import datetime, timezone
 
from bson import ObjectId
from database import get_navlinks_collection
from decorators.auth import requires_auth
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from glogger import logger
from models.navlink import NavLinkCreate, NavLinkResponse, NavLinkUpdate
from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import ValidationError
from utils import find_many_and_convert, find_one_and_convert
 
router = APIRouter()
 
 
@router.get("/navlinks")
async def get_navlinks(
    request: Request,
    response: Response,
    limit: int = Query(50, ge=1, le=100),
    offset: int = Query(0, ge=0),
    include_unpublished: bool = Query(False),
    collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
    """Get all navigation links with pagination."""
    try:
        query: dict = {}
 
        if not include_unpublished:
            query["is_published"] = True
 
        logger.info_with_context(
            "Fetching navlinks",
            {"limit": limit, "offset": offset},
        )
 
        total = await collection.count_documents(query)
        sort = [("sort_order", 1)]
 
        navlinks = await find_many_and_convert(
            collection,
            query,
            NavLinkResponse,
            sort=sort,
            limit=limit,
            skip=offset,
        )
 
        return {"items": navlinks, "total": total, "limit": limit, "offset": offset}
    except Exception as e:
        logger.exception_with_context("Error fetching navlinks", {"error": str(e)})
        raise HTTPException(
            status_code=500,
            detail={"message": "Error fetching navlinks", "error_type": type(e).__name__},
        )
 
 
@router.get("/navlinks/{navlink_id}", response_model=NavLinkResponse)
async def get_navlink(
    request: Request,
    navlink_id: str,
    collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
    """Get a navigation link by ID."""
    try:
        if not ObjectId.is_valid(navlink_id):
            raise HTTPException(status_code=400, detail="Invalid navlink ID format")
 
        navlink = await find_one_and_convert(
            collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
        )
 
        if not navlink:
            raise HTTPException(status_code=404, detail="NavLink not found")
 
        return navlink
    except HTTPException:
        raise
    except Exception as e:
        logger.exception_with_context("Error fetching navlink", {"error": str(e)})
        raise HTTPException(
            status_code=500,
            detail={"message": "Error fetching navlink", "error_type": type(e).__name__},
        )
 
 
@router.post("/navlinks", response_model=NavLinkResponse, status_code=201)
@requires_auth
async def create_navlink(
    request: Request,
    navlink: NavLinkCreate,
    collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
    """Create a new navigation link."""
    try:
        logger.info_with_context("Creating new navlink", {"label": navlink.label})
 
        current_time = datetime.now(timezone.utc)
        document = {
            **navlink.model_dump(),
            "createdDate": current_time,
            "updatedDate": current_time,
        }
 
        result = await collection.insert_one(document)
 
        created_navlink = await find_one_and_convert(
            collection, {"_id": result.inserted_id}, NavLinkResponse
        )
 
        if not created_navlink:
            raise HTTPException(
                status_code=500, detail="Failed to retrieve created navlink"
            )
 
        return created_navlink
    except ValidationError as e:
        raise HTTPException(status_code=400, detail={"message": "Validation error"})
    except Exception as e:
        logger.exception_with_context("Error creating navlink", {"error": str(e)})
        raise HTTPException(
            status_code=500,
            detail={"message": "Error creating navlink", "error_type": type(e).__name__},
        )
 
 
@router.put("/navlinks/{navlink_id}", response_model=NavLinkResponse)
@requires_auth
async def update_navlink(
    request: Request,
    navlink_id: str,
    navlink: NavLinkUpdate,
    collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
    """Update a navigation link."""
    try:
        if not ObjectId.is_valid(navlink_id):
            raise HTTPException(status_code=400, detail="Invalid navlink ID format")
 
        existing = await find_one_and_convert(
            collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
        )
 
        if not existing:
            raise HTTPException(status_code=404, detail="NavLink not found")
 
        current_time = datetime.now(timezone.utc)
        update_data = {k: v for k, v in navlink.model_dump().items() if v is not None}
        update_data["updatedDate"] = current_time
 
        result = await collection.update_one(
            {"_id": ObjectId(navlink_id)}, {"$set": update_data}
        )
 
        if result.modified_count == 0:
            raise HTTPException(status_code=500, detail="Failed to update navlink")
 
        updated = await find_one_and_convert(
            collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
        )
 
        return updated
    except HTTPException:
        raise
    except Exception as e:
        logger.exception_with_context("Error updating navlink", {"error": str(e)})
        raise HTTPException(
            status_code=500,
            detail={"message": "Error updating navlink", "error_type": type(e).__name__},
        )
 
 
@router.delete("/navlinks/{navlink_id}", status_code=204)
@requires_auth
async def delete_navlink(
    request: Request,
    navlink_id: str,
    collection: AsyncIOMotorCollection = Depends(get_navlinks_collection),
):
    """Delete a navigation link (hard delete)."""
    try:
        if not ObjectId.is_valid(navlink_id):
            raise HTTPException(status_code=400, detail="Invalid navlink ID format")
 
        existing = await find_one_and_convert(
            collection, {"_id": ObjectId(navlink_id)}, NavLinkResponse
        )
 
        if not existing:
            raise HTTPException(status_code=404, detail="NavLink not found")
 
        result = await collection.delete_one({"_id": ObjectId(navlink_id)})
 
        if result.deleted_count == 0:
            raise HTTPException(status_code=500, detail="Failed to delete navlink")
 
        logger.info_with_context("NavLink deleted", {"navlink_id": navlink_id})
    except HTTPException:
        raise
    except Exception as e:
        logger.exception_with_context("Error deleting navlink", {"error": str(e)})
        raise HTTPException(
            status_code=500,
            detail={"message": "Error deleting navlink", "error_type": type(e).__name__},
        )

Step 4: Register router in app.py

Add to backend/app.py imports:

from handlers.navlinks import router as navlinks_router

Add to route registration:

app.include_router(navlinks_router)

Step 5: Run test to verify it passes

Run: make test Expected: All navlinks tests PASS

Step 6: Commit

git add backend/handlers/navlinks.py backend/app.py backend/tests/test_navlinks_api.py
git commit -m "feat(backend): add navlinks CRUD endpoints
 
- GET /navlinks with pagination
- GET /navlinks/{id} by ID
- POST /navlinks creates with auth
- PUT /navlinks/{id} updates with auth
- DELETE /navlinks/{id} hard deletes with auth
- Tests for all endpoints
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 8: Format and Final Verification

Step 1: Run formatter

Run: make format Expected: Code formatted successfully

Step 2: Run all tests

Run: make test Expected: All tests PASS

Step 3: Run backend manually and test endpoints

Run: make dev-backend

Test in another terminal:

# Get sections (should return empty)
curl http://localhost:5001/sections
 
# Get navlinks (should return empty)
curl http://localhost:5001/navlinks

Expected: Both return {"items": [], "total": 0, ...}

Step 4: Final commit if any formatting changes

git add -A
git commit -m "chore: format code
 
🤖 Generated with [Claude Code](https://claude.com/claude-code)
 
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Phase 1 Complete Checklist

  • Local MongoDB works with make dev-backend
  • Section model with validation
  • NavLink model with validation
  • Database collections with indexes
  • Sections CRUD endpoints (GET, POST, PUT, DELETE)
  • NavLinks CRUD endpoints (GET, POST, PUT, DELETE)
  • All tests passing
  • Code formatted

Next Phase: Phase 2 - Migration Infrastructure (seed initial sections, backfill section_id)