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