---
phase: 09-content-management
plan: 05
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
  - summercms/src/content/menu/Menu.scala
  - summercms/src/content/menu/MenuItem.scala
  - summercms/src/content/menu/MenuItemType.scala
  - summercms/src/content/menu/MenuService.scala
  - summercms/src/content/menu/MenuItemTypeRegistry.scala
  - summercms/resources/db/migration/V012__navigation_menus.sql
  - summercms/src/api/admin/MenuRoutes.scala
  - summercms/src/content/cms/pebble/MenuFunction.scala
autonomous: true

must_haves:
  truths:
    - "Admin can create navigation menus with unique codes"
    - "Menu items support URL, CMS page reference, and plugin-generated types"
    - "Menu items can be nested (parent-child hierarchy)"
    - "Plugins can register custom menu item types"
    - "Templates can access menus via Pebble function"
  artifacts:
    - path: "summercms/src/content/menu/Menu.scala"
      provides: "Menu model"
      contains: "case class Menu"
    - path: "summercms/src/content/menu/MenuItem.scala"
      provides: "Menu item with polymorphic types"
      contains: "case class MenuItem"
    - path: "summercms/src/content/menu/MenuItemTypeRegistry.scala"
      provides: "Extensible item type registry"
      contains: "trait MenuItemTypeRegistry"
    - path: "summercms/resources/db/migration/V012__navigation_menus.sql"
      provides: "Menu and item tables"
      contains: "CREATE TABLE menus"
  key_links:
    - from: "MenuService.resolve"
      to: "MenuItemTypeRegistry"
      via: "resolves item type for URL generation"
      pattern: "typeRegistry\\.resolve"
    - from: "MenuFunction"
      to: "MenuService"
      via: "Pebble function fetches resolved menu"
      pattern: "menuService\\.getResolved"
---

<objective>
Implement navigation menus with extensible item types

Purpose: Enable admin to create and manage navigation menus. Menu items support static URLs, CMS page references (dynamic), and plugin-generated items (extensible). Menus accessible in templates via Pebble function.

Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function.
</objective>

<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/09-content-management/09-RESEARCH.md
@.planning/phases/09-content-management/09-01-SUMMARY.md (CmsRouter for page URL resolution)
@summercms/src/content/cms/CmsRouter.scala
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create menu models and database migration</name>
  <files>
    summercms/src/content/menu/Menu.scala
    summercms/src/content/menu/MenuItem.scala
    summercms/src/content/menu/MenuItemType.scala
    summercms/resources/db/migration/V012__navigation_menus.sql
  </files>
  <action>
**Menu.scala:**
```scala
case class Menu(
  id: Long,
  code: String,           // Unique identifier for theme reference: "main", "footer"
  name: String,           // Display name: "Main Navigation"
  createdAt: Instant,
  updatedAt: Instant
)

case class ResolvedMenu(
  code: String,
  name: String,
  items: List[ResolvedMenuItem]
)
```

**MenuItem.scala:**
```scala
case class MenuItem(
  id: Long,
  menuId: Long,
  parentId: Option[Long],        // For nested items
  itemType: String,              // "url", "cms-page", or plugin type
  title: Option[String],         // Display title (can inherit from page)
  reference: Option[String],     // URL or page reference or plugin key
  cssClass: Option[String],      // Optional CSS class
  nestDepth: Int,                // Computed depth for flat storage
  sortOrder: Int,
  isHidden: Boolean
)

case class ResolvedMenuItem(
  title: String,
  url: String,
  isActive: Boolean,             // True if current URL matches
  cssClass: Option[String],
  items: List[ResolvedMenuItem]  // Nested children
)
```

**MenuItemType.scala:**
Sealed trait for built-in types, extensible via registry:
```scala
sealed trait MenuItemType:
  def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem]

object MenuItemType:
  case object Url extends MenuItemType:
    def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem] =
      ZIO.succeed(ResolvedMenuItem(
        title = item.title.getOrElse("Link"),
        url = item.reference.getOrElse("#"),
        isActive = item.reference.contains(currentUrl),
        cssClass = item.cssClass,
        items = List.empty
      ))

  case object CmsPage extends MenuItemType:
    def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem] =
      for
        pageFile <- ZIO.fromOption(item.reference).orElseFail(MenuError.MissingReference(item.id))
        page     <- context.cmsRouter.load(pageFile).mapError(MenuError.PageLoadFailed(_))
        url      <- context.cmsRouter.pageUrl(page, Map.empty)
        title    = item.title.orElse(page.config.title).getOrElse(page.fileName)
      yield ResolvedMenuItem(
        title = title,
        url = url,
        isActive = url == currentUrl,
        cssClass = item.cssClass,
        items = List.empty
      )

case class MenuResolveContext(
  cmsRouter: CmsRouter,
  pluginResolvers: Map[String, MenuItemResolver]  // Plugin-provided resolvers
)

// Interface for plugin-provided menu item types
trait MenuItemResolver:
  def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem]
```

**V012__navigation_menus.sql:**
```sql
CREATE TABLE menus (
  id BIGSERIAL PRIMARY KEY,
  code VARCHAR(100) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE menu_items (
  id BIGSERIAL PRIMARY KEY,
  menu_id BIGINT NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
  parent_id BIGINT REFERENCES menu_items(id) ON DELETE CASCADE,
  item_type VARCHAR(50) NOT NULL DEFAULT 'url',
  title VARCHAR(255),
  reference VARCHAR(500),
  css_class VARCHAR(100),
  nest_depth INT NOT NULL DEFAULT 0,
  sort_order INT NOT NULL DEFAULT 0,
  is_hidden BOOLEAN NOT NULL DEFAULT false
);

CREATE INDEX idx_menu_items_menu ON menu_items(menu_id, sort_order);
CREATE INDEX idx_menu_items_parent ON menu_items(parent_id);

-- Insert default menus
INSERT INTO menus (code, name) VALUES
  ('main', 'Main Navigation'),
  ('footer', 'Footer Navigation');
```
  </action>
  <verify>
`./mill summercms.compile` succeeds with new models
Migration file syntactically valid
  </verify>
  <done>
Menu and MenuItem models with ResolvedMenuItem for frontend. MenuItemType trait for URL and CMS page types. Migration creates tables with default menus.
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement MenuItemTypeRegistry and MenuService</name>
  <files>
    summercms/src/content/menu/MenuItemTypeRegistry.scala
    summercms/src/content/menu/MenuService.scala
    summercms/src/content/menu/MenuRepository.scala
  </files>
  <action>
**MenuItemTypeRegistry.scala:**
Registry for extensible menu item types:
```scala
trait MenuItemTypeRegistry:
  def register(typeName: String, resolver: MenuItemResolver): UIO[Unit]
  def resolve(typeName: String): UIO[Option[MenuItemType | MenuItemResolver]]
  def listTypes: UIO[List[String]]

object MenuItemTypeRegistry:
  val live: ULayer[MenuItemTypeRegistry] =
    ZLayer.fromZIO {
      for
        registry <- Ref.make(Map[String, MenuItemType | MenuItemResolver](
          "url" -> MenuItemType.Url,
          "cms-page" -> MenuItemType.CmsPage
        ))
      yield new MenuItemTypeRegistry:
        def register(typeName: String, resolver: MenuItemResolver): UIO[Unit] =
          registry.update(_ + (typeName -> resolver))

        def resolve(typeName: String): UIO[Option[MenuItemType | MenuItemResolver]] =
          registry.get.map(_.get(typeName))

        def listTypes: UIO[List[String]] =
          registry.get.map(_.keys.toList)
    }
```

**MenuRepository.scala:**
Database operations:
```scala
trait MenuRepository:
  def findByCode(code: String): IO[RepositoryError, Option[Menu]]
  def findById(id: Long): IO[RepositoryError, Option[Menu]]
  def listAll: IO[RepositoryError, List[Menu]]
  def insert(menu: Menu): IO[RepositoryError, Menu]
  def update(menu: Menu): IO[RepositoryError, Menu]
  def delete(id: Long): IO[RepositoryError, Unit]

  def listItems(menuId: Long): IO[RepositoryError, List[MenuItem]]
  def insertItem(item: MenuItem): IO[RepositoryError, MenuItem]
  def updateItem(item: MenuItem): IO[RepositoryError, MenuItem]
  def deleteItem(id: Long): IO[RepositoryError, Unit]
  def reorderItems(menuId: Long, itemOrders: List[(Long, Int)]): IO[RepositoryError, Unit]
```

**MenuService.scala:**
Service with menu resolution:
```scala
trait MenuService:
  def getMenu(code: String): IO[MenuError, Option[Menu]]
  def getResolved(code: String, currentUrl: String): IO[MenuError, Option[ResolvedMenu]]
  def listMenus: IO[MenuError, List[Menu]]
  def createMenu(code: String, name: String): IO[MenuError, Menu]
  def updateMenu(id: Long, name: String): IO[MenuError, Menu]
  def deleteMenu(id: Long): IO[MenuError, Unit]

  def addItem(menuId: Long, item: MenuItem): IO[MenuError, MenuItem]
  def updateItem(item: MenuItem): IO[MenuError, MenuItem]
  def removeItem(id: Long): IO[MenuError, Unit]
  def reorderItems(menuId: Long, itemOrders: List[(Long, Int)]): IO[MenuError, Unit]

object MenuService:
  def live: ZLayer[MenuRepository & MenuItemTypeRegistry & CmsRouter, Nothing, MenuService] =
    ZLayer.fromFunction { (repo: MenuRepository, typeRegistry: MenuItemTypeRegistry, cmsRouter: CmsRouter) =>
      new MenuService:
        def getResolved(code: String, currentUrl: String): IO[MenuError, Option[ResolvedMenu]] =
          for
            menuOpt <- repo.findByCode(code).mapError(MenuError.RepositoryFailed(_))
            result <- menuOpt match
              case None => ZIO.succeed(None)
              case Some(menu) =>
                for
                  items <- repo.listItems(menu.id).mapError(MenuError.RepositoryFailed(_))
                  context = MenuResolveContext(cmsRouter, Map.empty)  // Plugin resolvers added at boot
                  resolved <- resolveItems(items, currentUrl, context)
                yield Some(ResolvedMenu(menu.code, menu.name, resolved))
          yield result

        private def resolveItems(
          items: List[MenuItem],
          currentUrl: String,
          context: MenuResolveContext
        ): IO[MenuError, List[ResolvedMenuItem]] =
          // Build tree structure from flat list
          val byParent = items.groupBy(_.parentId)
          def buildTree(parentId: Option[Long]): IO[MenuError, List[ResolvedMenuItem]] =
            ZIO.foreach(byParent.getOrElse(parentId, List.empty).filterNot(_.isHidden).sortBy(_.sortOrder)) { item =>
              for
                resolverOpt <- typeRegistry.resolve(item.itemType)
                resolver <- ZIO.fromOption(resolverOpt).orElseFail(MenuError.UnknownItemType(item.itemType))
                base <- resolver match
                  case t: MenuItemType => t.resolve(item, currentUrl, context)
                  case r: MenuItemResolver => r.resolve(item, currentUrl)
                children <- buildTree(Some(item.id))
              yield base.copy(items = children)
            }
          buildTree(None)
    }
```

Handle circular reference protection:
- Validate parentId != id on save
- Limit max depth (e.g., 5 levels)
- Use iterative tree building if recursion depth is concern
  </action>
  <verify>
`./mill summercms.compile` succeeds
MenuService.getResolved builds nested menu structure
Type registry allows plugin registration
  </verify>
  <done>
MenuItemTypeRegistry allows plugins to register custom item types. MenuService resolves menus with nested items, URL generation, and active state detection.
  </done>
</task>

<task type="auto">
  <name>Task 3: Create admin routes and Pebble menu function</name>
  <files>
    summercms/src/api/admin/MenuRoutes.scala
    summercms/src/content/cms/pebble/MenuFunction.scala
    summercms/src/content/cms/pebble/CmsPebbleExtension.scala
  </files>
  <action>
**MenuRoutes.scala:**
Admin API for menu management:
```scala
object MenuRoutes:
  val routes: Routes[MenuService, Response] =
    Routes(
      // List all menus
      Method.GET / "admin" / "api" / "menus" -> handler { (req: Request) =>
        for
          service <- ZIO.service[MenuService]
          menus   <- service.listMenus
        yield Response.json(menus.toJson)
      },

      // Get menu with items
      Method.GET / "admin" / "api" / "menus" / string("code") -> handler { (code: String, req: Request) =>
        for
          service <- ZIO.service[MenuService]
          menu    <- service.getMenu(code)
          items   <- menu.fold(ZIO.succeed(List.empty[MenuItem]))(m =>
            ZIO.serviceWithZIO[MenuRepository](_.listItems(m.id))
          )
        yield menu match
          case Some(m) => Response.json(MenuWithItems(m, items).toJson)
          case None    => Response.status(Status.NotFound)
      },

      // Create menu
      Method.POST / "admin" / "api" / "menus" -> handler { (req: Request) =>
        for
          body    <- req.body.asJson[CreateMenuRequest]
          service <- ZIO.service[MenuService]
          menu    <- service.createMenu(body.code, body.name)
        yield Response.json(menu.toJson).status(Status.Created)
      },

      // Update menu
      Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
        for
          body    <- req.body.asJson[UpdateMenuRequest]
          service <- ZIO.service[MenuService]
          menu    <- service.updateMenu(id, body.name)
        yield Response.json(menu.toJson)
      },

      // Delete menu
      Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
        for
          service <- ZIO.service[MenuService]
          _       <- service.deleteMenu(id)
        yield Response.ok
      },

      // Add menu item
      Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { (menuId: Long, req: Request) =>
        for
          body    <- req.body.asJson[CreateItemRequest]
          service <- ZIO.service[MenuService]
          item    <- service.addItem(menuId, body.toMenuItem(menuId))
        yield Response.json(item.toJson).status(Status.Created)
      },

      // Update menu item
      Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
        for
          body    <- req.body.asJson[UpdateItemRequest]
          service <- ZIO.service[MenuService]
          item    <- service.updateItem(body.toMenuItem(id))
        yield Response.json(item.toJson)
      },

      // Delete menu item
      Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
        for
          service <- ZIO.service[MenuService]
          _       <- service.removeItem(id)
        yield Response.ok
      },

      // Reorder items
      Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { (menuId: Long, req: Request) =>
        for
          body    <- req.body.asJson[ReorderRequest]
          service <- ZIO.service[MenuService]
          _       <- service.reorderItems(menuId, body.itemOrders)
        yield Response.ok
      },

      // List available item types
      Method.GET / "admin" / "api" / "menus" / "item-types" -> handler { (req: Request) =>
        for
          registry <- ZIO.service[MenuItemTypeRegistry]
          types    <- registry.listTypes
        yield Response.json(types.toJson)
      }
    )
```

**MenuFunction.scala:**
Pebble function for template access:
```scala
class MenuFunction(menuService: MenuService, runtime: Runtime[Any]) extends Function:
  override def getArgumentNames: java.util.List[String] =
    java.util.Arrays.asList("code")

  override def execute(
    args: java.util.Map[String, Object],
    self: PebbleTemplate,
    context: EvaluationContext,
    lineNumber: Int
  ): Object =
    val code = args.get("code").asInstanceOf[String]
    val currentUrl = context.getScopeChain.get("currentUrl").asInstanceOf[String]

    // Execute ZIO effect synchronously in template context
    Unsafe.unsafe { implicit u =>
      runtime.unsafe.run(
        menuService.getResolved(code, currentUrl)
      ).getOrThrowFiberFailure()
    } match
      case Some(menu) => menu.asJava  // Convert to Java collections for Pebble
      case None => null

// Usage in template:
// {% set mainMenu = menu('main') %}
// {% for item in mainMenu.items %}
//   <a href="{{ item.url }}" class="{{ item.isActive ? 'active' : '' }}">{{ item.title }}</a>
// {% endfor %}
```

**Update CmsPebbleExtension.scala:**
Register MenuFunction:
```scala
class CmsPebbleExtension(menuService: MenuService, runtime: Runtime[Any]) extends Extension:
  override def getFunctions: java.util.Map[String, Function] =
    java.util.Map.of(
      "menu", new MenuFunction(menuService, runtime)
    )
  // ... existing token parsers
```

Note: Extension now needs MenuService injected at construction time.
  </action>
  <verify>
`./mill summercms.compile` succeeds
MenuRoutes provide full CRUD
menu('main') function works in templates
  </verify>
  <done>
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus with active state detection. Item types extensible via registry.
  </done>
</task>

</tasks>

<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V012 ready for Flyway
3. Test menu CRUD:
   - Create menu with code "test"
   - Add URL item, CMS page item
   - Nest items under parent
   - Reorder items
4. Test menu resolution:
   - GET /admin/api/menus/main returns menu with items
   - Items have correct URLs and active state
5. Test Pebble function:
   - Template with `{% set nav = menu('main') %}` resolves menu
   - Loop over items renders navigation
6. Test extensibility:
   - Plugin can register custom item type
   - Custom type appears in item-types list
</verification>

<success_criteria>
- Menus created with unique codes for theme reference
- Menu items support URL, CMS page, and extensible plugin types
- Nested items maintain parent-child hierarchy
- Resolved menu includes active state for current URL
- Plugins can register custom menu item types via registry
- Templates access menus via menu('code') Pebble function
- Admin API provides full menu management
</success_criteria>

<output>
After completion, create `.planning/phases/09-content-management/09-05-SUMMARY.md`
</output>
