---
phase: 09-content-management
plan: 04
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
  - summercms/src/content/revision/ContentRevision.scala
  - summercms/src/content/revision/RevisionService.scala
  - summercms/src/content/revision/RevisionRepository.scala
  - summercms/resources/db/migration/V011__content_revisions.sql
  - summercms/src/content/cms/CmsPageService.scala
  - summercms/src/api/admin/RevisionRoutes.scala
autonomous: true

must_haves:
  truths:
    - "Content revisions are tracked when pages are saved"
    - "Revisions include full content snapshot with version number"
    - "Admin can list revision history for any content item"
    - "Admin can view a specific revision's content"
    - "Content state (draft/published) transitions are recorded"
  artifacts:
    - path: "summercms/src/content/revision/ContentRevision.scala"
      provides: "Revision model with version tracking"
      contains: "case class ContentRevision"
    - path: "summercms/src/content/revision/RevisionService.scala"
      provides: "Revision CRUD operations"
      contains: "trait RevisionService"
    - path: "summercms/resources/db/migration/V011__content_revisions.sql"
      provides: "Revision history table"
      contains: "CREATE TABLE content_revisions"
  key_links:
    - from: "CmsPageService.save"
      to: "RevisionService.createRevision"
      via: "creates revision before saving"
      pattern: "revisions\\.createRevision"
    - from: "RevisionService"
      to: "RevisionRepository"
      via: "database operations"
      pattern: "repo\\.(insert|find)"
---

<objective>
Implement content states and revision history tracking

Purpose: Track draft/published states and maintain revision history for CMS pages. Per CONTEXT.md decisions: two states only (Draft, Published), revision behavior at Claude's discretion.

Output: ContentRevision model, RevisionService with retention policy, database migration, CmsPageService integration, admin revision viewing routes.
</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
@summercms/src/content/cms/CmsPage.scala
@summercms/src/content/cms/CmsPageService.scala
@summercms/src/content/cms/ContentState.scala
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create revision model and database migration</name>
  <files>
    summercms/src/content/revision/ContentRevision.scala
    summercms/resources/db/migration/V011__content_revisions.sql
  </files>
  <action>
**ContentRevision.scala:**
```scala
case class ContentRevision(
  id: Long,
  contentType: String,          // "page", "layout", "partial"
  contentKey: String,           // File path: "pages/blog/post.htm"
  version: Int,                 // Incremental version number
  content: String,              // Full content snapshot
  state: ContentState,          // State at this revision
  authorId: Long,               // Backend user who made change
  description: Option[String],  // Optional change description
  createdAt: Instant
)

case class RevisionSummary(
  id: Long,
  version: Int,
  state: ContentState,
  authorId: Long,
  authorName: String,           // Joined from backend_users
  description: Option[String],
  createdAt: Instant
)

// For listing revisions without full content
```

**V011__content_revisions.sql:**
```sql
CREATE TABLE content_revisions (
  id BIGSERIAL PRIMARY KEY,
  content_type VARCHAR(50) NOT NULL,
  content_key VARCHAR(255) NOT NULL,
  version INT NOT NULL,
  content TEXT NOT NULL,
  state VARCHAR(20) NOT NULL,
  author_id BIGINT NOT NULL,
  description TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  -- Composite unique constraint
  CONSTRAINT uq_revision_version UNIQUE(content_type, content_key, version)
);

-- Index for efficient lookup
CREATE INDEX idx_revisions_lookup ON content_revisions(content_type, content_key, version DESC);
CREATE INDEX idx_revisions_author ON content_revisions(author_id);

-- Note: author_id not FK to backend_users for now to avoid Phase 6 dependency
-- Will add FK constraint after Phase 6 executes

COMMENT ON TABLE content_revisions IS 'Stores full snapshots of content for revision history';
```
  </action>
  <verify>
`./mill summercms.compile` succeeds with new models
Migration file syntactically valid
  </verify>
  <done>
ContentRevision model for tracking content changes. Migration creates revision table with version uniqueness constraint and efficient indexes.
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement RevisionService with retention policy</name>
  <files>
    summercms/src/content/revision/RevisionRepository.scala
    summercms/src/content/revision/RevisionService.scala
  </files>
  <action>
**RevisionRepository.scala:**
Repository for revision database operations using Quill:
```scala
trait RevisionRepository:
  def insert(revision: ContentRevision): IO[RepositoryError, ContentRevision]
  def find(id: Long): IO[RepositoryError, Option[ContentRevision]]
  def findByKey(contentType: String, contentKey: String, version: Int): IO[RepositoryError, Option[ContentRevision]]
  def listByKey(contentType: String, contentKey: String, limit: Int): IO[RepositoryError, List[RevisionSummary]]
  def getLatestVersion(contentType: String, contentKey: String): IO[RepositoryError, Int]
  def deleteOlderThan(contentType: String, contentKey: String, keepCount: Int): IO[RepositoryError, Long]
  def deleteByKey(contentType: String, contentKey: String): IO[RepositoryError, Long]

object RevisionRepository:
  val live: ZLayer[QuillContext, Nothing, RevisionRepository] = ...
```

**RevisionService.scala:**
Service layer with revision logic:
```scala
trait RevisionService:
  def createRevision(
    contentType: String,
    contentKey: String,
    content: String,
    state: ContentState,
    authorId: Long,
    description: Option[String] = None
  ): IO[RevisionError, ContentRevision]

  def listRevisions(
    contentType: String,
    contentKey: String,
    limit: Int = 50
  ): IO[RevisionError, List[RevisionSummary]]

  def getRevision(id: Long): IO[RevisionError, Option[ContentRevision]]

  def getRevisionByVersion(
    contentType: String,
    contentKey: String,
    version: Int
  ): IO[RevisionError, Option[ContentRevision]]

  def rollback(
    contentType: String,
    contentKey: String,
    toVersion: Int,
    authorId: Long
  ): IO[RevisionError, ContentRevision]

case class RevisionConfig(
  maxRevisionsPerItem: Int = 50,  // Keep last N revisions per content item
  retentionDays: Int = 90         // Or delete if older than N days
)

object RevisionService:
  def live(config: RevisionConfig): ZLayer[RevisionRepository, Nothing, RevisionService] =
    ZLayer.fromFunction { (repo: RevisionRepository) =>
      new RevisionService:
        def createRevision(
          contentType: String,
          contentKey: String,
          content: String,
          state: ContentState,
          authorId: Long,
          description: Option[String]
        ): IO[RevisionError, ContentRevision] =
          for
            currentVersion <- repo.getLatestVersion(contentType, contentKey)
            revision = ContentRevision(
              id = 0,  // DB-generated
              contentType = contentType,
              contentKey = contentKey,
              version = currentVersion + 1,
              content = content,
              state = state,
              authorId = authorId,
              description = description,
              createdAt = Instant.now
            )
            saved <- repo.insert(revision)
            // Cleanup old revisions beyond max count
            _ <- repo.deleteOlderThan(contentType, contentKey, config.maxRevisionsPerItem)
          yield saved

        def rollback(
          contentType: String,
          contentKey: String,
          toVersion: Int,
          authorId: Long
        ): IO[RevisionError, ContentRevision] =
          for
            oldRevision <- getRevisionByVersion(contentType, contentKey, toVersion)
              .someOrFail(RevisionError.VersionNotFound(toVersion))
            // Create new revision with old content
            newRevision <- createRevision(
              contentType,
              contentKey,
              oldRevision.content,
              oldRevision.state,
              authorId,
              Some(s"Rollback to version $toVersion")
            )
          yield newRevision
    }
```

Revision decisions (per CONTEXT.md "Claude's discretion"):
- Create revision on every save (not just publish)
- Keep last 50 revisions per item
- Rollback creates new revision (preserves history)
  </action>
  <verify>
`./mill summercms.compile` succeeds
RevisionService methods have correct signatures
  </verify>
  <done>
RevisionRepository handles database operations. RevisionService provides revision creation with automatic version numbering, cleanup policy, and rollback capability.
  </done>
</task>

<task type="auto">
  <name>Task 3: Integrate with CmsPageService and add revision routes</name>
  <files>
    summercms/src/content/cms/CmsPageService.scala
    summercms/src/api/admin/RevisionRoutes.scala
    summercms/src/api/admin/PageRoutes.scala
  </files>
  <action>
**Update CmsPageService.scala:**
Add RevisionService dependency and create revisions on save:

```scala
trait CmsPageService:
  def save(page: CmsPage, authorId: Long, description: Option[String] = None): IO[PageError, CmsPage]
  def publish(fileName: String, authorId: Long): IO[PageError, CmsPage]
  def unpublish(fileName: String, authorId: Long): IO[PageError, CmsPage]
  // ... existing methods

// In live implementation:
def save(page: CmsPage, authorId: Long, description: Option[String]): IO[PageError, CmsPage] =
  for
    // Create revision before saving
    content <- ZIO.succeed(serializePage(page))
    _ <- revisions.createRevision(
      "page",
      page.fileName,
      content,
      page.config.state,
      authorId,
      description
    )
    // Write to file
    _ <- ZIO.attemptBlocking {
      Files.writeString(themePath.resolve(page.fileName), content)
    }
    // Update metadata cache
    _ <- updateMetadata(page)
    // Reload to get fresh mtime
    saved <- load(page.fileName)
  yield saved

def publish(fileName: String, authorId: Long): IO[PageError, CmsPage] =
  for
    page <- load(fileName)
    updated = page.copy(config = page.config.copy(state = ContentState.Published))
    saved <- save(updated, authorId, Some("Published"))
  yield saved

def unpublish(fileName: String, authorId: Long): IO[PageError, CmsPage] =
  for
    page <- load(fileName)
    updated = page.copy(config = page.config.copy(state = ContentState.Draft))
    saved <- save(updated, authorId, Some("Unpublished"))
  yield saved
```

**RevisionRoutes.scala:**
Admin routes for revision management:
```scala
object RevisionRoutes:
  val routes: Routes[RevisionService, Response] =
    Routes(
      // List revisions for content item
      Method.GET / "admin" / "api" / "revisions" / string("type") / trailing -> handler {
        (contentType: String, path: Path, req: Request) =>
          for
            contentKey <- ZIO.succeed(path.encode)
            limit      <- ZIO.succeed(req.url.queryParams.getOrElse("limit", "50").toIntOption.getOrElse(50))
            service    <- ZIO.service[RevisionService]
            revisions  <- service.listRevisions(contentType, contentKey, limit)
          yield Response.json(revisions.toJson)
      },

      // Get specific revision content
      Method.GET / "admin" / "api" / "revisions" / long("id") -> handler { (id: Long, req: Request) =>
        for
          service  <- ZIO.service[RevisionService]
          revision <- service.getRevision(id)
        yield revision match
          case Some(r) => Response.json(r.toJson)
          case None    => Response.status(Status.NotFound)
      },

      // Rollback to specific version
      Method.POST / "admin" / "api" / "revisions" / string("type") / trailing / "rollback" / int("version") ->
        handler { (contentType: String, path: Path, version: Int, req: Request) =>
          for
            contentKey <- ZIO.succeed(path.encode)
            authorId   <- extractAuthorId(req)  // From session/JWT
            service    <- ZIO.service[RevisionService]
            revision   <- service.rollback(contentType, contentKey, version, authorId)
          yield Response.json(revision.toJson)
        }
    )
```

**PageRoutes.scala (new or update existing):**
Admin routes for page operations including state changes:
```scala
// Publish endpoint
Method.POST / "admin" / "api" / "pages" / trailing / "publish" -> handler {
  (path: Path, req: Request) =>
    for
      authorId <- extractAuthorId(req)
      service  <- ZIO.service[CmsPageService]
      page     <- service.publish(path.encode, authorId)
    yield Response.json(page.toJson)
}

// Unpublish endpoint
Method.POST / "admin" / "api" / "pages" / trailing / "unpublish" -> handler {
  (path: Path, req: Request) =>
    for
      authorId <- extractAuthorId(req)
      service  <- ZIO.service[CmsPageService]
      page     <- service.unpublish(path.encode, authorId)
    yield Response.json(page.toJson)
}
```
  </action>
  <verify>
`./mill summercms.compile` succeeds
CmsPageService.save creates revision
Revision routes accessible
  </verify>
  <done>
CmsPageService creates revisions on save/publish/unpublish. RevisionRoutes provides admin API for viewing history and rollback. State changes tracked with descriptions.
  </done>
</task>

</tasks>

<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V011 ready for Flyway
3. Test revision creation:
   - Save page -> revision created with version 1
   - Save again -> revision created with version 2
   - List revisions returns both
4. Test publish/unpublish:
   - Publish page -> state changes to Published, revision created
   - Unpublish -> state changes to Draft, revision created
5. Test rollback:
   - Rollback to version 1 -> creates version 3 with version 1 content
   - Original versions preserved
</verification>

<success_criteria>
- Content revisions created automatically on save
- Version numbers increment correctly per content item
- Revisions include full content snapshot and state
- Publish/unpublish create revisions with state change
- Revision list available via admin API
- Rollback creates new revision (history preserved)
- Retention policy limits revision count per item
</success_criteria>

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