Files
summercms-initial-research/.planning/phases/08-admin-dashboard/08-02-PLAN.md
Jakub Zych 5b0928f292 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
2026-02-05 15:16:30 +01:00

427 lines
14 KiB
Markdown

---
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>