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
427 lines
14 KiB
Markdown
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>
|