Files
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

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
08-admin-dashboard 02 execute 1
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
true
truths artifacts key_links
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
path provides exports
summercms/admin/src/settings/SettingsManager.scala Registry for plugin settings pages
SettingsManager
SettingsItem
path provides contains
summercms/admin/src/settings/categories.scala Standard settings categories object SettingsCategory
path provides contains
summercms/admin/src/settings/SettingsModel.scala Behavior for YAML-driven settings persistence trait SettingsModel
path provides contains
summercms/db/src/main/resources/db/migration/V006__system_settings.sql System settings storage table CREATE TABLE system_settings
from to via pattern
SettingsManager SettingsItem registerSettings accepts items def registerSettings.*SettingsItem
from to via pattern
SettingsModel system_settings table loadFromDatabase/saveToDatabase 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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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

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:

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:

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:

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

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:

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

<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>
After completion, create `.planning/phases/08-admin-dashboard/08-02-SUMMARY.md`