--- 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)" --- 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. @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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 Task 1: Create revision model and database migration summercms/src/content/revision/ContentRevision.scala summercms/resources/db/migration/V011__content_revisions.sql **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'; ``` `./mill summercms.compile` succeeds with new models Migration file syntactically valid ContentRevision model for tracking content changes. Migration creates revision table with version uniqueness constraint and efficient indexes. Task 2: Implement RevisionService with retention policy summercms/src/content/revision/RevisionRepository.scala summercms/src/content/revision/RevisionService.scala **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) `./mill summercms.compile` succeeds RevisionService methods have correct signatures RevisionRepository handles database operations. RevisionService provides revision creation with automatic version numbering, cleanup policy, and rollback capability. Task 3: Integrate with CmsPageService and add revision routes summercms/src/content/cms/CmsPageService.scala summercms/src/api/admin/RevisionRoutes.scala summercms/src/api/admin/PageRoutes.scala **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) } ``` `./mill summercms.compile` succeeds CmsPageService.save creates revision Revision routes accessible CmsPageService creates revisions on save/publish/unpublish. RevisionRoutes provides admin API for viewing history and rollback. State changes tracked with descriptions. 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 - 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 After completion, create `.planning/phases/09-content-management/09-04-SUMMARY.md`