---
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
  - summercms/test/content/menu/MenuPluginIntegrationSpec.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 that render correctly"
    - "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"
    - path: "summercms/test/content/menu/MenuPluginIntegrationSpec.scala"
      provides: "Plugin integration test"
      contains: "MenuPluginIntegrationSpec"
  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"
    - from: "Plugin registration"
      to: "MenuItemTypeRegistry.register"
      via: "plugin boot registers custom type"
      pattern: "registry\\.register"
---

<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, plugin integration test.
</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, Pebble function, and plugin integration test</name>
  <files>
    summercms/src/api/admin/MenuRoutes.scala
    summercms/src/content/cms/pebble/MenuFunction.scala
    summercms/src/content/cms/pebble/CmsPebbleExtension.scala
    summercms/test/content/menu/MenuPluginIntegrationSpec.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 { ... },

      // Get menu with items
      Method.GET / "admin" / "api" / "menus" / string("code") -> handler { ... },

      // Create menu
      Method.POST / "admin" / "api" / "menus" -> handler { ... },

      // Update menu
      Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { ... },

      // Delete menu
      Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { ... },

      // Add menu item
      Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { ... },

      // Update menu item
      Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },

      // Delete menu item
      Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },

      // Reorder items
      Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { ... },

      // List available item types (includes plugin-registered 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 (constructor now needs MenuService).

**MenuPluginIntegrationSpec.scala:**
Test demonstrating plugin integration (CONT-09 requirement):
```scala
class MenuPluginIntegrationSpec extends ZIOSpecDefault:
  // Example plugin-provided menu item type: "blog-category"
  // Resolves blog category IDs to category pages

  class BlogCategoryResolver(blogService: BlogService) extends MenuItemResolver:
    def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem] =
      for
        categoryId <- ZIO.fromOption(item.reference.flatMap(_.toLongOption))
          .orElseFail(MenuError.InvalidReference(item.id, "Expected category ID"))
        category <- blogService.getCategory(categoryId)
          .someOrFail(MenuError.ReferenceNotFound(item.id, s"Category $categoryId"))
        url = s"/blog/category/${category.slug}"
      yield ResolvedMenuItem(
        title = item.title.getOrElse(category.name),
        url = url,
        isActive = currentUrl == url || currentUrl.startsWith(s"$url/"),
        cssClass = item.cssClass,
        items = List.empty
      )

  def spec = suite("MenuPluginIntegration")(
    test("plugin can register custom menu item type") {
      for
        registry <- ZIO.service[MenuItemTypeRegistry]
        blogService = TestBlogService()
        resolver = new BlogCategoryResolver(blogService)

        // Plugin registers its type at boot
        _ <- registry.register("blog-category", resolver)

        // Verify type is registered
        types <- registry.listTypes
        _ <- assertTrue(types.contains("blog-category"))

        // Verify type appears in admin API
        // GET /admin/api/menus/item-types should include "blog-category"
      yield assertTrue(types.contains("url"), types.contains("cms-page"), types.contains("blog-category"))
    },

    test("plugin-registered type resolves correctly in menu") {
      for
        registry <- ZIO.service[MenuItemTypeRegistry]
        menuService <- ZIO.service[MenuService]
        blogService = TestBlogService.withCategory(1L, "Tech", "tech")
        resolver = new BlogCategoryResolver(blogService)

        _ <- registry.register("blog-category", resolver)

        // Create menu with plugin item type
        menu <- menuService.createMenu("test-menu", "Test Menu")
        _ <- menuService.addItem(menu.id, MenuItem(
          id = 0,
          menuId = menu.id,
          parentId = None,
          itemType = "blog-category",  // Plugin-provided type
          title = None,                 // Will inherit from category
          reference = Some("1"),        // Category ID
          cssClass = None,
          nestDepth = 0,
          sortOrder = 0,
          isHidden = false
        ))

        // Resolve menu - plugin type should work
        resolved <- menuService.getResolved("test-menu", "/other")
      yield assertTrue(
        resolved.isDefined,
        resolved.get.items.head.title == "Tech",
        resolved.get.items.head.url == "/blog/category/tech",
        resolved.get.items.head.isActive == false
      )
    },

    test("plugin type renders correctly in Pebble template") {
      for
        registry <- ZIO.service[MenuItemTypeRegistry]
        menuService <- ZIO.service[MenuService]
        blogService = TestBlogService.withCategory(1L, "Tech", "tech")

        _ <- registry.register("blog-category", new BlogCategoryResolver(blogService))

        // Setup menu with plugin item
        menu <- menuService.createMenu("nav", "Navigation")
        _ <- menuService.addItem(menu.id, MenuItem(/* blog-category type */))

        // Render template using menu
        template = """
          {% set nav = menu('nav') %}
          {% for item in nav.items %}
          <a href="{{ item.url }}">{{ item.title }}</a>
          {% endfor %}
        """
        rendered <- renderPebbleTemplate(template, Map("currentUrl" -> "/"))
      yield assertTrue(
        rendered.contains("""<a href="/blog/category/tech">Tech</a>""")
      )
    }
  ).provide(
    MenuItemTypeRegistry.live,
    MenuService.live,
    MenuRepository.live,
    // ... test dependencies
  )
```
  </action>
  <verify>
`./mill summercms.compile` succeeds
`./mill summercms.test` - MenuPluginIntegrationSpec passes
menu('main') function works in templates
Plugin-registered type appears in item-types API endpoint
  </verify>
  <done>
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus. Plugin integration test demonstrates custom menu item type registration, resolution, and template rendering.
  </done>
</task>

</tasks>

<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. `./mill summercms.test` - plugin integration tests pass
3. Migration V012 ready for Flyway
4. Test menu CRUD:
   - Create menu with code "test"
   - Add URL item, CMS page item
   - Nest items under parent
   - Reorder items
5. Test menu resolution:
   - GET /admin/api/menus/main returns menu with items
   - Items have correct URLs and active state
6. Test Pebble function:
   - Template with `{% set nav = menu('main') %}` resolves menu
   - Loop over items renders navigation
7. Test plugin extensibility (CONT-09):
   - Plugin can register custom item type via registry
   - Custom type appears in GET /admin/api/menus/item-types
   - Menu items with custom type resolve correctly
   - Custom type renders correctly in Pebble template
</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
- Plugin-registered types appear in item-types API
- Plugin types resolve to valid ResolvedMenuItem with correct URLs
- Plugin types render correctly in Pebble templates
- Templates access menus via menu('code') Pebble function
- Admin API provides full menu management
- Integration test proves end-to-end plugin type functionality
</success_criteria>

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