Phase 09: Content Management - 6 plan(s) in 3 wave(s) - Wave 1: 09-01 (CMS pages/layouts), 09-03 (media library) - parallel - Wave 2: 09-02 (component embedding), 09-04 (revisions), 09-05 (menus) - Wave 3: 09-06 (hot reload) - Ready for execution
14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 09-content-management | 04 | execute | 2 |
|
|
true |
|
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.
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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 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';
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.compilesucceeds RevisionService methods have correct signatures RevisionRepository handles database operations. RevisionService provides revision creation with automatic version numbering, cleanup policy, and rollback capability.
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:
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:
// 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)
}
<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>