docs(09): create phase plan
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
This commit is contained in:
427
.planning/phases/09-content-management/09-04-PLAN.md
Normal file
427
.planning/phases/09-content-management/09-04-PLAN.md
Normal file
@@ -0,0 +1,427 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user