diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt new file mode 100644 index 0000000..e5c6cc4 --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt @@ -0,0 +1,174 @@ +package org.vibeerp.platform.metadata.rules + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.event.DomainEvent +import org.vibeerp.api.v1.event.EventBus + +/** + * Event-condition-action engine backed by `metadata__rule` rows. + * + * On startup (and after every rule mutation via [RuleController]) the engine + * reads every enabled rule from the database, groups them by topic, and + * registers [EventBus] subscriptions. When an event fires, the engine + * evaluates each matching rule's conditions through [RuleEvaluator] and + * executes the declared actions for rules that match. + * + * Currently supported action types: + * - `log` -- writes a templated message at INFO level. Templates may + * reference event fields with `{fieldName}` placeholders. + * + * New action types (send-email, create-entity, webhook, ...) are added as + * additional `when` branches in [executeActions]; plug-in-contributed action + * types will be supported via a registry once the demand is clear. + */ +@Component +class RuleEngine( + private val eventBus: EventBus, + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, +) { + private val subscriptions = mutableListOf() + private val log = LoggerFactory.getLogger(RuleEngine::class.java) + + @PostConstruct + fun init() { + refresh() + } + + /** + * Reload all enabled rules from the database and re-subscribe to the + * event bus. Called on startup and after every rule CRUD operation. + */ + fun refresh() { + // 1. Tear down existing subscriptions + subscriptions.forEach { runCatching { it.close() } } + subscriptions.clear() + + // 2. Load all enabled rules + val rules = loadEnabledRules() + if (rules.isEmpty()) { + log.info("RuleEngine: no enabled rules loaded") + return + } + + // 3. Group by trigger topic and subscribe + val byTopic = rules.groupBy { it.triggerTopic } + byTopic.forEach { (topic, topicRules) -> + val sub = eventBus.subscribe(topic) { event -> + val eventMap = objectMapper.convertValue( + event, + object : TypeReference>() {}, + ) + topicRules.forEach { rule -> + try { + val conditions = rule.conditions.map { + RuleEvaluator.Condition(it.field, it.operator, it.value) + } + if (RuleEvaluator.evaluate(eventMap, conditions, rule.conditionLogic)) { + executeActions(rule, eventMap) + } + } catch (e: Exception) { + log.error("[RULE:{}] evaluation failed: {}", rule.slug, e.message) + } + } + } + subscriptions.add(sub) + } + log.info("RuleEngine: loaded {} enabled rules across {} topics", rules.size, byTopic.size) + } + + private fun executeActions(rule: LoadedRule, eventMap: Map) { + rule.actions.forEach { action -> + when (action.type) { + "log" -> { + var msg = action.config["message"] ?: "Rule '${rule.name}' fired" + // Simple template substitution: {fieldName} -> value from event + eventMap.forEach { (k, v) -> + msg = msg.replace("{$k}", v?.toString() ?: "") + } + log.info("[RULE:{}] {}", rule.slug, msg) + } + else -> log.warn("[RULE:{}] unknown action type '{}' -- skipping", rule.slug, action.type) + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun loadEnabledRules(): List { + return jdbc.query( + "SELECT payload FROM metadata__rule WHERE payload->>'enabled' != 'false'", + MapSqlParameterSource(), + ) { rs, _ -> + try { + val payload = objectMapper.readValue( + rs.getString("payload"), + Map::class.java, + ) as Map + LoadedRule( + slug = payload["slug"]?.toString() ?: "", + name = payload["name"]?.toString() ?: "", + triggerEvent = payload["triggerEvent"]?.toString() ?: "", + triggerTopic = mapEventToTopic(payload["triggerEvent"]?.toString() ?: ""), + conditionLogic = payload["conditionLogic"]?.toString() ?: "AND", + conditions = (payload["conditions"] as? List<*>)?.mapNotNull { c -> + val cm = c as? Map<*, *> ?: return@mapNotNull null + LoadedCondition( + cm["field"]?.toString() ?: "", + cm["operator"]?.toString() ?: "eq", + cm["value"]?.toString() ?: "", + ) + } ?: emptyList(), + actions = (payload["actions"] as? List<*>)?.mapNotNull { a -> + val am = a as? Map<*, *> ?: return@mapNotNull null + LoadedAction( + am["type"]?.toString() ?: "log", + (am["config"] as? Map<*, *>) + ?.map { (k, v) -> k.toString() to v.toString() } + ?.toMap() + ?: emptyMap(), + ) + } ?: emptyList(), + ) + } catch (e: Exception) { + log.warn("RuleEngine: skipping malformed rule row: {}", e.message) + null + } + }.filterNotNull() + } + + /** + * Map event class simple names to aggregate topics. + * + * Rules are authored with human-readable trigger event names like + * `SalesOrderConfirmed`. This maps them to the topic string used + * by the [EventBus] for subscription routing. + */ + private fun mapEventToTopic(eventName: String): String { + return when { + eventName.startsWith("SalesOrder") -> "orders_sales.SalesOrder" + eventName.startsWith("PurchaseOrder") -> "orders_purchase.PurchaseOrder" + eventName.startsWith("WorkOrder") -> "production.WorkOrder" + eventName.startsWith("Inspection") -> "quality.InspectionRecord" + else -> eventName // fallback: use as-is + } + } + + private data class LoadedRule( + val slug: String, + val name: String, + val triggerEvent: String, + val triggerTopic: String, + val conditionLogic: String, + val conditions: List, + val actions: List, + ) + + private data class LoadedCondition(val field: String, val operator: String, val value: String) + private data class LoadedAction(val type: String, val config: Map) +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt index 49570ce..6b3e8c4 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt @@ -34,6 +34,7 @@ data class MetadataYamlFile( val customFields: List = emptyList(), val forms: List = emptyList(), val listViews: List = emptyList(), + val rules: List = emptyList(), ) /** @@ -308,8 +309,8 @@ data class ListViewSortYaml( * A quick-filter control in a [ListViewYaml]. * * @property field The entity field this filter operates on. - * @property operator The comparison operator (`eq`, `ne`, `lt`, `gt`, - * `le`, `ge`, `contains`, `startsWith`, `in`). + * @property operator The comparison operator (`eq`, `neq`, `lt`, `gt`, + * `gte`, `lte`, `contains`, `in`). * @property label Display label for the filter control. */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -318,3 +319,76 @@ data class ListViewFilterYaml( val operator: String = "eq", val label: String = "", ) + +/** + * An automation rule triggered by a business event. + * + * Rules are the Tier 1 (no-code) equivalent of event listeners: a key + * user defines "when event X fires and conditions Y hold, execute + * actions Z" through the customization UI or through plug-in YAML. + * The rule engine evaluates conditions at runtime and dispatches the + * configured actions (log, set-field, send-notification, call-webhook, + * etc.). + * + * @property slug Stable identifier. Convention: + * `-` (e.g. `orders-high-value-alert`). + * @property name Human-readable name shown in the rule list UI. + * @property description Optional longer explanation of the rule's + * purpose. + * @property enabled Whether the rule is active. Disabled rules are + * loaded but skipped by the engine. + * @property triggerEvent The fully-qualified or short event class name + * this rule reacts to (e.g. `SalesOrderConfirmedEvent`). + * @property conditions Ordered list of field-level predicates that + * must hold for the actions to fire. + * @property conditionLogic How conditions combine: `AND` (all must + * match) or `OR` (any must match). + * @property actions Ordered list of actions to execute when the + * conditions are satisfied. + * @property version Schema version for forward-compat migration. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RuleYaml( + val slug: String = "", + val name: String = "", + val description: String? = null, + val enabled: Boolean = true, + val triggerEvent: String = "", + val conditions: List = emptyList(), + val conditionLogic: String = "AND", + val actions: List = emptyList(), + val version: Int = 1, +) + +/** + * A single field-level predicate within a [RuleYaml]. + * + * @property field The event/entity field to evaluate (e.g. + * `totalAmount`, `status`). + * @property operator Comparison operator: `eq`, `neq`, `lt`, `gt`, + * `gte`, `lte`, `contains`, `in`. + * @property value The literal value to compare against, always + * represented as a string (the engine coerces to the field's + * declared type at evaluation time). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RuleConditionYaml( + val field: String = "", + val operator: String = "eq", + val value: String = "", +) + +/** + * An action to execute when a [RuleYaml]'s conditions are met. + * + * @property type The action type identifier. Built-in types include + * `log`, `set_field`, `send_notification`, `call_webhook`. Plug-ins + * can register additional action types. + * @property config Key-value configuration specific to the action type + * (e.g. `message` for `log`, `url` + `method` for `call_webhook`). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RuleActionYaml( + val type: String = "log", + val config: Map = emptyMap(), +) diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt index 728f6c5..ba51c67 100644 --- a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt @@ -113,6 +113,8 @@ class SecurityConfiguration { "/journal-entries", "/journal-entries/**", "/users", "/users/**", "/roles", "/roles/**", + "/workflow", "/workflow/**", + "/admin", "/admin/**", ).permitAll() // Anything else — return 401 so a typoed deep link