docs(08): create phase plan
Phase 08: Admin Dashboard - 4 plan(s) in 2 wave(s) - 2 parallel (08-01, 08-02), 2 sequential (08-03, 08-04) - Ready for execution
This commit is contained in:
426
.planning/phases/08-admin-dashboard/08-02-PLAN.md
Normal file
426
.planning/phases/08-admin-dashboard/08-02-PLAN.md
Normal file
@@ -0,0 +1,426 @@
|
||||
---
|
||||
phase: 08-admin-dashboard
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- summercms/admin/src/settings/SettingsManager.scala
|
||||
- summercms/admin/src/settings/categories.scala
|
||||
- summercms/admin/src/settings/SettingsModel.scala
|
||||
- summercms/db/src/main/resources/db/migration/V006__system_settings.sql
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Plugins can register settings pages with category and permissions"
|
||||
- "Settings are grouped by category when displayed"
|
||||
- "Settings models persist values to database using YAML-driven forms"
|
||||
artifacts:
|
||||
- path: "summercms/admin/src/settings/SettingsManager.scala"
|
||||
provides: "Registry for plugin settings pages"
|
||||
exports: ["SettingsManager", "SettingsItem"]
|
||||
- path: "summercms/admin/src/settings/categories.scala"
|
||||
provides: "Standard settings categories"
|
||||
contains: "object SettingsCategory"
|
||||
- path: "summercms/admin/src/settings/SettingsModel.scala"
|
||||
provides: "Behavior for YAML-driven settings persistence"
|
||||
contains: "trait SettingsModel"
|
||||
- path: "summercms/db/src/main/resources/db/migration/V006__system_settings.sql"
|
||||
provides: "System settings storage table"
|
||||
contains: "CREATE TABLE system_settings"
|
||||
key_links:
|
||||
- from: "SettingsManager"
|
||||
to: "SettingsItem"
|
||||
via: "registerSettings accepts items"
|
||||
pattern: "def registerSettings.*SettingsItem"
|
||||
- from: "SettingsModel"
|
||||
to: "system_settings table"
|
||||
via: "loadFromDatabase/saveToDatabase"
|
||||
pattern: "system_settings"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the plugin settings system: a manager for registering settings pages, standard categories for grouping, and a model behavior for YAML-driven settings persistence.
|
||||
|
||||
Purpose: Enable plugins to define settings pages using the same YAML-driven form system from Phase 7
|
||||
Output: SettingsManager, SettingsCategory, SettingsModel, migration
|
||||
</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/08-admin-dashboard/08-RESEARCH.md
|
||||
|
||||
# Phase 1 Quill patterns
|
||||
@summercms/db/src/main/scala/summercms/db/QuillContext.scala
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Settings Manager and Categories</name>
|
||||
<files>
|
||||
summercms/admin/src/settings/SettingsManager.scala
|
||||
summercms/admin/src/settings/categories.scala
|
||||
</files>
|
||||
<action>
|
||||
Create the settings registration system:
|
||||
|
||||
**categories.scala** - Standard settings categories with sort order:
|
||||
|
||||
```scala
|
||||
package summercms.admin.settings
|
||||
|
||||
/** Standard settings categories following WinterCMS conventions */
|
||||
object SettingsCategory:
|
||||
val System = CategoryDef("system", "System", 100)
|
||||
val CMS = CategoryDef("cms", "CMS", 200)
|
||||
val Users = CategoryDef("users", "Users", 300)
|
||||
val Mail = CategoryDef("mail", "Mail", 400)
|
||||
val Misc = CategoryDef("misc", "Miscellaneous", 900)
|
||||
|
||||
val all: List[CategoryDef] = List(System, CMS, Users, Mail, Misc)
|
||||
|
||||
def byCode(code: String): Option[CategoryDef] =
|
||||
all.find(_.code == code)
|
||||
|
||||
case class CategoryDef(
|
||||
code: String,
|
||||
label: String,
|
||||
order: Int
|
||||
)
|
||||
```
|
||||
|
||||
**SettingsManager.scala** - Registry for plugin settings:
|
||||
|
||||
```scala
|
||||
package summercms.admin.settings
|
||||
|
||||
import zio._
|
||||
|
||||
/** A registered settings page */
|
||||
case class SettingsItem(
|
||||
code: String,
|
||||
owner: String, // Plugin ID: "golem15.blog"
|
||||
label: String, // Display name or translation key
|
||||
description: Option[String] = None,
|
||||
category: String, // Category code (system, cms, users, etc.)
|
||||
icon: Option[String] = None,
|
||||
url: Option[String] = None, // Custom URL or auto-generated
|
||||
settingsClass: Option[String] = None, // Settings model class name
|
||||
permissions: List[String] = Nil,
|
||||
order: Int = 500,
|
||||
keywords: Option[String] = None // For search
|
||||
)
|
||||
|
||||
/** Context tracking current settings page being edited */
|
||||
case class SettingsContext(
|
||||
owner: String,
|
||||
code: String
|
||||
)
|
||||
|
||||
/** Registry for plugin settings pages */
|
||||
trait SettingsManager:
|
||||
/** Register settings items from a plugin */
|
||||
def registerSettings(owner: String, items: List[SettingsItem]): UIO[Unit]
|
||||
|
||||
/** List all items grouped by category */
|
||||
def listItems: UIO[Map[String, List[SettingsItem]]]
|
||||
|
||||
/** Find a specific settings item */
|
||||
def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
|
||||
|
||||
/** Set current context for settings operations */
|
||||
def setContext(owner: String, code: String): UIO[Unit]
|
||||
|
||||
/** Get current context */
|
||||
def getContext: UIO[Option[SettingsContext]]
|
||||
|
||||
object SettingsManager:
|
||||
val live: ZLayer[Any, Nothing, SettingsManager] =
|
||||
ZLayer.fromZIO {
|
||||
for
|
||||
itemsRef <- Ref.make(Map.empty[String, SettingsItem])
|
||||
contextRef <- Ref.make(Option.empty[SettingsContext])
|
||||
yield new SettingsManager:
|
||||
def registerSettings(owner: String, items: List[SettingsItem]): UIO[Unit] =
|
||||
itemsRef.update { current =>
|
||||
items.foldLeft(current) { case (acc, item) =>
|
||||
val key = s"${owner.toUpperCase}.${item.code.toUpperCase}"
|
||||
val itemWithOwner = item.copy(
|
||||
owner = owner,
|
||||
url = item.url.orElse(Some(generateSettingsUrl(owner, item.code)))
|
||||
)
|
||||
acc + (key -> itemWithOwner)
|
||||
}
|
||||
}
|
||||
|
||||
def listItems: UIO[Map[String, List[SettingsItem]]] =
|
||||
itemsRef.get.map { items =>
|
||||
items.values.toList
|
||||
.groupBy(_.category)
|
||||
.view
|
||||
.mapValues(_.sortBy(_.order))
|
||||
.toMap
|
||||
}
|
||||
|
||||
def findItem(owner: String, code: String): UIO[Option[SettingsItem]] =
|
||||
itemsRef.get.map(_.get(s"${owner.toUpperCase}.${code.toUpperCase}"))
|
||||
|
||||
def setContext(owner: String, code: String): UIO[Unit] =
|
||||
contextRef.set(Some(SettingsContext(owner.toLowerCase, code.toLowerCase)))
|
||||
|
||||
def getContext: UIO[Option[SettingsContext]] =
|
||||
contextRef.get
|
||||
|
||||
private def generateSettingsUrl(owner: String, code: String): String =
|
||||
val parts = owner.split('.')
|
||||
s"/admin/system/settings/update/${parts.mkString("/")}/$code"
|
||||
}
|
||||
|
||||
// Accessor methods
|
||||
def registerSettings(owner: String, items: List[SettingsItem]): ZIO[SettingsManager, Nothing, Unit] =
|
||||
ZIO.serviceWithZIO[SettingsManager](_.registerSettings(owner, items))
|
||||
|
||||
def listItems: ZIO[SettingsManager, Nothing, Map[String, List[SettingsItem]]] =
|
||||
ZIO.serviceWithZIO[SettingsManager](_.listItems)
|
||||
|
||||
def findItem(owner: String, code: String): ZIO[SettingsManager, Nothing, Option[SettingsItem]] =
|
||||
ZIO.serviceWithZIO[SettingsManager](_.findItem(owner, code))
|
||||
```
|
||||
|
||||
Categories follow WinterCMS pattern. Plugins can use existing categories or define custom ones (just use any string code).
|
||||
</action>
|
||||
<verify>
|
||||
mill summercms.admin.compile succeeds
|
||||
SettingsCategory.all contains System, CMS, Users, Mail, Misc
|
||||
SettingsManager.live layer constructs
|
||||
</verify>
|
||||
<done>
|
||||
SettingsCategory defines standard categories with order
|
||||
SettingsItem captures all metadata for a settings page
|
||||
SettingsManager provides registration and lookup with ZIO Ref
|
||||
Auto-generated URLs follow /admin/system/settings/update/{author}/{plugin}/{code} pattern
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Settings Model Behavior</name>
|
||||
<files>
|
||||
summercms/admin/src/settings/SettingsModel.scala
|
||||
summercms/db/src/main/resources/db/migration/V006__system_settings.sql
|
||||
</files>
|
||||
<action>
|
||||
Create the settings persistence layer:
|
||||
|
||||
**V006__system_settings.sql** - Migration for settings storage:
|
||||
|
||||
```sql
|
||||
-- System settings storage (key-value with JSON)
|
||||
CREATE TABLE system_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
item VARCHAR(255) NOT NULL UNIQUE,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_system_settings_item ON system_settings(item);
|
||||
|
||||
-- Trigger to update updated_at
|
||||
CREATE TRIGGER update_system_settings_updated_at
|
||||
BEFORE UPDATE ON system_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
```
|
||||
|
||||
**SettingsModel.scala** - Trait for settings persistence:
|
||||
|
||||
```scala
|
||||
package summercms.admin.settings
|
||||
|
||||
import io.circe._
|
||||
import io.circe.syntax._
|
||||
import zio._
|
||||
import summercms.db.QuillContext
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/** Database row for system_settings */
|
||||
case class SystemSetting(
|
||||
id: Long,
|
||||
item: String,
|
||||
value: Json,
|
||||
createdAt: OffsetDateTime,
|
||||
updatedAt: OffsetDateTime
|
||||
)
|
||||
|
||||
/** Base trait for plugin settings models */
|
||||
trait SettingsModel:
|
||||
/** Unique code identifying this settings group */
|
||||
def settingsCode: String
|
||||
|
||||
/** Path to fields.yaml defining the form */
|
||||
def settingsFields: String
|
||||
|
||||
/** Initialize default values (override in subclass) */
|
||||
def initSettingsData(): Map[String, Json] = Map.empty
|
||||
|
||||
/** Service for settings model operations */
|
||||
trait SettingsModelService:
|
||||
/** Get settings values, loading from DB or defaults */
|
||||
def get(model: SettingsModel): Task[Map[String, Json]]
|
||||
|
||||
/** Get a single setting value */
|
||||
def getValue[T: Decoder](model: SettingsModel, key: String, default: T): Task[T]
|
||||
|
||||
/** Set settings values */
|
||||
def set(model: SettingsModel, values: Map[String, Json]): Task[Unit]
|
||||
|
||||
/** Reset to defaults by deleting from DB */
|
||||
def resetDefault(model: SettingsModel): Task[Unit]
|
||||
|
||||
object SettingsModelService:
|
||||
val live: ZLayer[QuillContext, Nothing, SettingsModelService] =
|
||||
ZLayer.fromZIO {
|
||||
ZIO.service[QuillContext].map { quill =>
|
||||
import quill.ctx._
|
||||
|
||||
// Cache for loaded settings
|
||||
val cache = new java.util.concurrent.ConcurrentHashMap[String, Map[String, Json]]()
|
||||
|
||||
new SettingsModelService:
|
||||
def get(model: SettingsModel): Task[Map[String, Json]] =
|
||||
// Check cache first
|
||||
Option(cache.get(model.settingsCode)) match
|
||||
case Some(values) => ZIO.succeed(values)
|
||||
case None =>
|
||||
for
|
||||
rows <- run(
|
||||
query[SystemSetting]
|
||||
.filter(_.item == lift(model.settingsCode))
|
||||
)
|
||||
values <- rows.headOption match
|
||||
case Some(row) =>
|
||||
ZIO.fromEither(row.value.as[Map[String, Json]])
|
||||
.mapError(e => new RuntimeException(s"Failed to decode settings: ${e.getMessage}"))
|
||||
case None =>
|
||||
ZIO.succeed(model.initSettingsData())
|
||||
_ <- ZIO.succeed(cache.put(model.settingsCode, values))
|
||||
yield values
|
||||
|
||||
def getValue[T: Decoder](model: SettingsModel, key: String, default: T): Task[T] =
|
||||
get(model).map { values =>
|
||||
values.get(key)
|
||||
.flatMap(_.as[T].toOption)
|
||||
.getOrElse(default)
|
||||
}
|
||||
|
||||
def set(model: SettingsModel, values: Map[String, Json]): Task[Unit] =
|
||||
val valuesJson = values.asJson
|
||||
val now = OffsetDateTime.now()
|
||||
for
|
||||
existing <- run(
|
||||
query[SystemSetting]
|
||||
.filter(_.item == lift(model.settingsCode))
|
||||
)
|
||||
_ <- existing.headOption match
|
||||
case Some(row) =>
|
||||
run(
|
||||
query[SystemSetting]
|
||||
.filter(_.id == lift(row.id))
|
||||
.update(
|
||||
_.value -> lift(valuesJson),
|
||||
_.updatedAt -> lift(now)
|
||||
)
|
||||
).unit
|
||||
case None =>
|
||||
run(
|
||||
query[SystemSetting]
|
||||
.insert(
|
||||
_.item -> lift(model.settingsCode),
|
||||
_.value -> lift(valuesJson),
|
||||
_.createdAt -> lift(now),
|
||||
_.updatedAt -> lift(now)
|
||||
)
|
||||
).unit
|
||||
// Invalidate cache
|
||||
_ <- ZIO.succeed(cache.remove(model.settingsCode))
|
||||
yield ()
|
||||
|
||||
def resetDefault(model: SettingsModel): Task[Unit] =
|
||||
for
|
||||
_ <- run(
|
||||
query[SystemSetting]
|
||||
.filter(_.item == lift(model.settingsCode))
|
||||
.delete
|
||||
)
|
||||
_ <- ZIO.succeed(cache.remove(model.settingsCode))
|
||||
yield ()
|
||||
}
|
||||
}
|
||||
|
||||
// Accessor methods
|
||||
def get(model: SettingsModel): ZIO[SettingsModelService, Throwable, Map[String, Json]] =
|
||||
ZIO.serviceWithZIO[SettingsModelService](_.get(model))
|
||||
|
||||
def set(model: SettingsModel, values: Map[String, Json]): ZIO[SettingsModelService, Throwable, Unit] =
|
||||
ZIO.serviceWithZIO[SettingsModelService](_.set(model, values))
|
||||
```
|
||||
|
||||
The SettingsModelService provides caching to avoid repeated DB hits. Cache is invalidated on write. Plugin settings models implement SettingsModel trait with their settingsCode and fields path.
|
||||
|
||||
Example usage by a plugin:
|
||||
|
||||
```scala
|
||||
// In a plugin
|
||||
object BlogSettings extends SettingsModel:
|
||||
val settingsCode = "golem15_blog_settings"
|
||||
val settingsFields = "$/plugins/golem15/blog/models/settings/fields.yaml"
|
||||
|
||||
override def initSettingsData(): Map[String, Json] = Map(
|
||||
"posts_per_page" -> Json.fromInt(10),
|
||||
"show_author" -> Json.fromBoolean(true)
|
||||
)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
mill summercms.db.flywayMigrate runs migration V006 successfully
|
||||
mill summercms.admin.compile succeeds
|
||||
SettingsModelService compiles with get/set/resetDefault
|
||||
</verify>
|
||||
<done>
|
||||
Migration V006 creates system_settings table with JSONB value column
|
||||
SystemSetting model maps to table
|
||||
SettingsModel trait defines settingsCode and settingsFields
|
||||
SettingsModelService provides get/set with caching
|
||||
Cache invalidation on write prevents stale reads
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `mill summercms.admin.compile` succeeds
|
||||
2. `mill summercms.db.flywayMigrate` applies V006 migration
|
||||
3. Database has system_settings table with correct schema
|
||||
4. SettingsCategory.byCode("system") returns System category
|
||||
5. SettingsManager and SettingsModelService layers construct
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SettingsCategory provides standard categories (System, CMS, Users, Mail, Misc)
|
||||
- SettingsManager registers and groups settings by category
|
||||
- SettingsModel trait established for plugin settings
|
||||
- SettingsModelService persists to system_settings table with caching
|
||||
- Migration creates system_settings with JSONB support
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-admin-dashboard/08-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user