Commit da7ebd3ce3f67ac9eea8a184d4e41c57b1bd7271

Authored by zichun
1 parent a3176480

fix: security config SPA routes + KDoc operator name + rule query filter

platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.rules
  2 +
  3 +import com.fasterxml.jackson.core.type.TypeReference
  4 +import com.fasterxml.jackson.databind.ObjectMapper
  5 +import jakarta.annotation.PostConstruct
  6 +import org.slf4j.LoggerFactory
  7 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  8 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  9 +import org.springframework.stereotype.Component
  10 +import org.vibeerp.api.v1.event.DomainEvent
  11 +import org.vibeerp.api.v1.event.EventBus
  12 +
  13 +/**
  14 + * Event-condition-action engine backed by `metadata__rule` rows.
  15 + *
  16 + * On startup (and after every rule mutation via [RuleController]) the engine
  17 + * reads every enabled rule from the database, groups them by topic, and
  18 + * registers [EventBus] subscriptions. When an event fires, the engine
  19 + * evaluates each matching rule's conditions through [RuleEvaluator] and
  20 + * executes the declared actions for rules that match.
  21 + *
  22 + * Currently supported action types:
  23 + * - `log` -- writes a templated message at INFO level. Templates may
  24 + * reference event fields with `{fieldName}` placeholders.
  25 + *
  26 + * New action types (send-email, create-entity, webhook, ...) are added as
  27 + * additional `when` branches in [executeActions]; plug-in-contributed action
  28 + * types will be supported via a registry once the demand is clear.
  29 + */
  30 +@Component
  31 +class RuleEngine(
  32 + private val eventBus: EventBus,
  33 + private val jdbc: NamedParameterJdbcTemplate,
  34 + private val objectMapper: ObjectMapper,
  35 +) {
  36 + private val subscriptions = mutableListOf<EventBus.Subscription>()
  37 + private val log = LoggerFactory.getLogger(RuleEngine::class.java)
  38 +
  39 + @PostConstruct
  40 + fun init() {
  41 + refresh()
  42 + }
  43 +
  44 + /**
  45 + * Reload all enabled rules from the database and re-subscribe to the
  46 + * event bus. Called on startup and after every rule CRUD operation.
  47 + */
  48 + fun refresh() {
  49 + // 1. Tear down existing subscriptions
  50 + subscriptions.forEach { runCatching { it.close() } }
  51 + subscriptions.clear()
  52 +
  53 + // 2. Load all enabled rules
  54 + val rules = loadEnabledRules()
  55 + if (rules.isEmpty()) {
  56 + log.info("RuleEngine: no enabled rules loaded")
  57 + return
  58 + }
  59 +
  60 + // 3. Group by trigger topic and subscribe
  61 + val byTopic = rules.groupBy { it.triggerTopic }
  62 + byTopic.forEach { (topic, topicRules) ->
  63 + val sub = eventBus.subscribe(topic) { event ->
  64 + val eventMap = objectMapper.convertValue(
  65 + event,
  66 + object : TypeReference<Map<String, Any?>>() {},
  67 + )
  68 + topicRules.forEach { rule ->
  69 + try {
  70 + val conditions = rule.conditions.map {
  71 + RuleEvaluator.Condition(it.field, it.operator, it.value)
  72 + }
  73 + if (RuleEvaluator.evaluate(eventMap, conditions, rule.conditionLogic)) {
  74 + executeActions(rule, eventMap)
  75 + }
  76 + } catch (e: Exception) {
  77 + log.error("[RULE:{}] evaluation failed: {}", rule.slug, e.message)
  78 + }
  79 + }
  80 + }
  81 + subscriptions.add(sub)
  82 + }
  83 + log.info("RuleEngine: loaded {} enabled rules across {} topics", rules.size, byTopic.size)
  84 + }
  85 +
  86 + private fun executeActions(rule: LoadedRule, eventMap: Map<String, Any?>) {
  87 + rule.actions.forEach { action ->
  88 + when (action.type) {
  89 + "log" -> {
  90 + var msg = action.config["message"] ?: "Rule '${rule.name}' fired"
  91 + // Simple template substitution: {fieldName} -> value from event
  92 + eventMap.forEach { (k, v) ->
  93 + msg = msg.replace("{$k}", v?.toString() ?: "")
  94 + }
  95 + log.info("[RULE:{}] {}", rule.slug, msg)
  96 + }
  97 + else -> log.warn("[RULE:{}] unknown action type '{}' -- skipping", rule.slug, action.type)
  98 + }
  99 + }
  100 + }
  101 +
  102 + @Suppress("UNCHECKED_CAST")
  103 + private fun loadEnabledRules(): List<LoadedRule> {
  104 + return jdbc.query(
  105 + "SELECT payload FROM metadata__rule WHERE payload->>'enabled' != 'false'",
  106 + MapSqlParameterSource(),
  107 + ) { rs, _ ->
  108 + try {
  109 + val payload = objectMapper.readValue(
  110 + rs.getString("payload"),
  111 + Map::class.java,
  112 + ) as Map<String, Any?>
  113 + LoadedRule(
  114 + slug = payload["slug"]?.toString() ?: "",
  115 + name = payload["name"]?.toString() ?: "",
  116 + triggerEvent = payload["triggerEvent"]?.toString() ?: "",
  117 + triggerTopic = mapEventToTopic(payload["triggerEvent"]?.toString() ?: ""),
  118 + conditionLogic = payload["conditionLogic"]?.toString() ?: "AND",
  119 + conditions = (payload["conditions"] as? List<*>)?.mapNotNull { c ->
  120 + val cm = c as? Map<*, *> ?: return@mapNotNull null
  121 + LoadedCondition(
  122 + cm["field"]?.toString() ?: "",
  123 + cm["operator"]?.toString() ?: "eq",
  124 + cm["value"]?.toString() ?: "",
  125 + )
  126 + } ?: emptyList(),
  127 + actions = (payload["actions"] as? List<*>)?.mapNotNull { a ->
  128 + val am = a as? Map<*, *> ?: return@mapNotNull null
  129 + LoadedAction(
  130 + am["type"]?.toString() ?: "log",
  131 + (am["config"] as? Map<*, *>)
  132 + ?.map { (k, v) -> k.toString() to v.toString() }
  133 + ?.toMap()
  134 + ?: emptyMap(),
  135 + )
  136 + } ?: emptyList(),
  137 + )
  138 + } catch (e: Exception) {
  139 + log.warn("RuleEngine: skipping malformed rule row: {}", e.message)
  140 + null
  141 + }
  142 + }.filterNotNull()
  143 + }
  144 +
  145 + /**
  146 + * Map event class simple names to aggregate topics.
  147 + *
  148 + * Rules are authored with human-readable trigger event names like
  149 + * `SalesOrderConfirmed`. This maps them to the topic string used
  150 + * by the [EventBus] for subscription routing.
  151 + */
  152 + private fun mapEventToTopic(eventName: String): String {
  153 + return when {
  154 + eventName.startsWith("SalesOrder") -> "orders_sales.SalesOrder"
  155 + eventName.startsWith("PurchaseOrder") -> "orders_purchase.PurchaseOrder"
  156 + eventName.startsWith("WorkOrder") -> "production.WorkOrder"
  157 + eventName.startsWith("Inspection") -> "quality.InspectionRecord"
  158 + else -> eventName // fallback: use as-is
  159 + }
  160 + }
  161 +
  162 + private data class LoadedRule(
  163 + val slug: String,
  164 + val name: String,
  165 + val triggerEvent: String,
  166 + val triggerTopic: String,
  167 + val conditionLogic: String,
  168 + val conditions: List<LoadedCondition>,
  169 + val actions: List<LoadedAction>,
  170 + )
  171 +
  172 + private data class LoadedCondition(val field: String, val operator: String, val value: String)
  173 + private data class LoadedAction(val type: String, val config: Map<String, String>)
  174 +}
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt
@@ -34,6 +34,7 @@ data class MetadataYamlFile( @@ -34,6 +34,7 @@ data class MetadataYamlFile(
34 val customFields: List<CustomFieldYaml> = emptyList(), 34 val customFields: List<CustomFieldYaml> = emptyList(),
35 val forms: List<FormYaml> = emptyList(), 35 val forms: List<FormYaml> = emptyList(),
36 val listViews: List<ListViewYaml> = emptyList(), 36 val listViews: List<ListViewYaml> = emptyList(),
  37 + val rules: List<RuleYaml> = emptyList(),
37 ) 38 )
38 39
39 /** 40 /**
@@ -308,8 +309,8 @@ data class ListViewSortYaml( @@ -308,8 +309,8 @@ data class ListViewSortYaml(
308 * A quick-filter control in a [ListViewYaml]. 309 * A quick-filter control in a [ListViewYaml].
309 * 310 *
310 * @property field The entity field this filter operates on. 311 * @property field The entity field this filter operates on.
311 - * @property operator The comparison operator (`eq`, `ne`, `lt`, `gt`,  
312 - * `le`, `ge`, `contains`, `startsWith`, `in`). 312 + * @property operator The comparison operator (`eq`, `neq`, `lt`, `gt`,
  313 + * `gte`, `lte`, `contains`, `in`).
313 * @property label Display label for the filter control. 314 * @property label Display label for the filter control.
314 */ 315 */
315 @JsonIgnoreProperties(ignoreUnknown = true) 316 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -318,3 +319,76 @@ data class ListViewFilterYaml( @@ -318,3 +319,76 @@ data class ListViewFilterYaml(
318 val operator: String = "eq", 319 val operator: String = "eq",
319 val label: String = "", 320 val label: String = "",
320 ) 321 )
  322 +
  323 +/**
  324 + * An automation rule triggered by a business event.
  325 + *
  326 + * Rules are the Tier 1 (no-code) equivalent of event listeners: a key
  327 + * user defines "when event X fires and conditions Y hold, execute
  328 + * actions Z" through the customization UI or through plug-in YAML.
  329 + * The rule engine evaluates conditions at runtime and dispatches the
  330 + * configured actions (log, set-field, send-notification, call-webhook,
  331 + * etc.).
  332 + *
  333 + * @property slug Stable identifier. Convention:
  334 + * `<pbc>-<short-description>` (e.g. `orders-high-value-alert`).
  335 + * @property name Human-readable name shown in the rule list UI.
  336 + * @property description Optional longer explanation of the rule's
  337 + * purpose.
  338 + * @property enabled Whether the rule is active. Disabled rules are
  339 + * loaded but skipped by the engine.
  340 + * @property triggerEvent The fully-qualified or short event class name
  341 + * this rule reacts to (e.g. `SalesOrderConfirmedEvent`).
  342 + * @property conditions Ordered list of field-level predicates that
  343 + * must hold for the actions to fire.
  344 + * @property conditionLogic How conditions combine: `AND` (all must
  345 + * match) or `OR` (any must match).
  346 + * @property actions Ordered list of actions to execute when the
  347 + * conditions are satisfied.
  348 + * @property version Schema version for forward-compat migration.
  349 + */
  350 +@JsonIgnoreProperties(ignoreUnknown = true)
  351 +data class RuleYaml(
  352 + val slug: String = "",
  353 + val name: String = "",
  354 + val description: String? = null,
  355 + val enabled: Boolean = true,
  356 + val triggerEvent: String = "",
  357 + val conditions: List<RuleConditionYaml> = emptyList(),
  358 + val conditionLogic: String = "AND",
  359 + val actions: List<RuleActionYaml> = emptyList(),
  360 + val version: Int = 1,
  361 +)
  362 +
  363 +/**
  364 + * A single field-level predicate within a [RuleYaml].
  365 + *
  366 + * @property field The event/entity field to evaluate (e.g.
  367 + * `totalAmount`, `status`).
  368 + * @property operator Comparison operator: `eq`, `neq`, `lt`, `gt`,
  369 + * `gte`, `lte`, `contains`, `in`.
  370 + * @property value The literal value to compare against, always
  371 + * represented as a string (the engine coerces to the field's
  372 + * declared type at evaluation time).
  373 + */
  374 +@JsonIgnoreProperties(ignoreUnknown = true)
  375 +data class RuleConditionYaml(
  376 + val field: String = "",
  377 + val operator: String = "eq",
  378 + val value: String = "",
  379 +)
  380 +
  381 +/**
  382 + * An action to execute when a [RuleYaml]'s conditions are met.
  383 + *
  384 + * @property type The action type identifier. Built-in types include
  385 + * `log`, `set_field`, `send_notification`, `call_webhook`. Plug-ins
  386 + * can register additional action types.
  387 + * @property config Key-value configuration specific to the action type
  388 + * (e.g. `message` for `log`, `url` + `method` for `call_webhook`).
  389 + */
  390 +@JsonIgnoreProperties(ignoreUnknown = true)
  391 +data class RuleActionYaml(
  392 + val type: String = "log",
  393 + val config: Map<String, String> = emptyMap(),
  394 +)
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
@@ -113,6 +113,8 @@ class SecurityConfiguration { @@ -113,6 +113,8 @@ class SecurityConfiguration {
113 "/journal-entries", "/journal-entries/**", 113 "/journal-entries", "/journal-entries/**",
114 "/users", "/users/**", 114 "/users", "/users/**",
115 "/roles", "/roles/**", 115 "/roles", "/roles/**",
  116 + "/workflow", "/workflow/**",
  117 + "/admin", "/admin/**",
116 ).permitAll() 118 ).permitAll()
117 119
118 // Anything else — return 401 so a typoed deep link 120 // Anything else — return 401 so a typoed deep link