---
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"
---
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
@/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/08-admin-dashboard/08-RESEARCH.md
# Phase 1 Quill patterns
@summercms/db/src/main/scala/summercms/db/QuillContext.scala
Task 1: Settings Manager and Categories
summercms/admin/src/settings/SettingsManager.scala
summercms/admin/src/settings/categories.scala
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).
mill summercms.admin.compile succeeds
SettingsCategory.all contains System, CMS, Users, Mail, Misc
SettingsManager.live layer constructs
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
Task 2: Settings Model Behavior
summercms/admin/src/settings/SettingsModel.scala
summercms/db/src/main/resources/db/migration/V006__system_settings.sql
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)
)
```
mill summercms.db.flywayMigrate runs migration V006 successfully
mill summercms.admin.compile succeeds
SettingsModelService compiles with get/set/resetDefault
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
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
- 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