diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/PluginJobHandlerRegistrar.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/PluginJobHandlerRegistrar.kt new file mode 100644 index 0000000..b716dff --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/PluginJobHandlerRegistrar.kt @@ -0,0 +1,51 @@ +package org.vibeerp.api.v1.jobs + +/** + * The per-plug-in registrar a plug-in uses to register its own + * [JobHandler] implementations with the framework's Quartz-backed + * scheduler. + * + * Wired at plug-in start time via + * [org.vibeerp.api.v1.plugin.PluginContext.jobs]. A plug-in calls it + * from inside its `start(context)` lambda: + * + * ``` + * class PrintingShopPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { + * override fun start(context: PluginContext) { + * context.jobs.register(PlateCleanupJobHandler(context)) + * context.jobs.register(QuoteExpiryJobHandler(context)) + * } + * } + * ``` + * + * Exactly mirrors the [org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar] + * seam landed with P2.1's plug-in-loaded TaskHandler support: + * - Ownership is invisible to the plug-in author. The host records + * each call against the plug-in's id so the framework can + * `unregisterAll` at stop time without the plug-in tracking the + * keys it registered. + * - Plug-ins cannot impersonate another plug-in's namespace. The + * registrar is constructed by the host with a fixed plug-in id. + * - The handler may hold a reference to the PluginContext captured + * at registration time (same "handler-side context access" + * pattern the framework's workflow handlers use) so it can write + * to the plug-in's own tables via `context.jdbc`, log through + * `context.logger`, and publish events via `context.eventBus`. + * + * Duplicate-key rejection: if a plug-in tries to register a + * [JobHandler.key] that a core handler or another plug-in already + * owns, [register] throws `IllegalStateException` and the plug-in's + * `start(context)` fails. The plug-in loader strips any partial + * registrations the plug-in made before throwing so a half- + * registered state cannot linger. + */ +public interface PluginJobHandlerRegistrar { + + /** + * Register a [JobHandler] with the framework so manual + * `POST /api/v1/jobs/handlers/{key}/trigger` calls and scheduled + * cron/one-shot jobs targeting [JobHandler.key] are dispatched + * to it. + */ + public fun register(handler: JobHandler) +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt index 87525f8..a8a8803 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt @@ -5,6 +5,7 @@ import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.i18n.LocaleProvider import org.vibeerp.api.v1.i18n.Translator import org.vibeerp.api.v1.files.FileStorage +import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar import org.vibeerp.api.v1.persistence.Transaction import org.vibeerp.api.v1.security.PermissionCheck import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar @@ -129,6 +130,24 @@ interface PluginContext { "PluginContext.files is not implemented by this host. " + "Upgrade vibe_erp to v0.8 or later." ) + + /** + * Register [org.vibeerp.api.v1.jobs.JobHandler] implementations + * with the framework's Quartz-backed scheduler. Plug-ins that + * ship recurring or one-shot background work supply a handler + * per job key through this registrar. + * + * Added in api.v1.0.x alongside P1.10 (platform-jobs). Default + * implementation throws so an older host running a newer plug-in + * jar fails loudly at first call rather than silently dropping + * scheduled work. Backward-compat pattern matches `endpoints`, + * `jdbc`, `taskHandlers`, and `files`. + */ + val jobs: PluginJobHandlerRegistrar + get() = throw UnsupportedOperationException( + "PluginContext.jobs is not implemented by this host. " + + "Upgrade vibe_erp to v0.9 or later." + ) } /** diff --git a/platform/platform-plugins/build.gradle.kts b/platform/platform-plugins/build.gradle.kts index f1aa23e..219df99 100644 --- a/platform/platform-plugins/build.gradle.kts +++ b/platform/platform-plugins/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring implementation(project(":platform:platform-workflow")) // for per-plug-in TaskHandler registration + implementation(project(":platform:platform-jobs")) // for per-plug-in JobHandler registration implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt index 412569b..de69cdb 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt @@ -9,6 +9,7 @@ import org.vibeerp.api.v1.persistence.Transaction import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar import org.vibeerp.api.v1.plugin.PluginJdbc +import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar import org.vibeerp.api.v1.plugin.PluginLogger import org.vibeerp.api.v1.security.PermissionCheck import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar @@ -49,6 +50,7 @@ internal class DefaultPluginContext( private val pluginTranslator: Translator, private val sharedLocaleProvider: LocaleProvider, private val scopedTaskHandlers: PluginTaskHandlerRegistrar, + private val scopedJobHandlers: PluginJobHandlerRegistrar, ) : PluginContext { override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) @@ -107,6 +109,16 @@ internal class DefaultPluginContext( */ override val taskHandlers: PluginTaskHandlerRegistrar = scopedTaskHandlers + /** + * Per-plug-in [PluginJobHandlerRegistrar] wired in P1.10's + * follow-up. The scoped registrar tags every registration with + * this plug-in's id so the framework's [JobHandlerRegistry] can + * strip every handler the plug-in contributed with a single + * `unregisterAllByOwner(pluginId)` call at plug-in stop time. + * Same discipline as the workflow-side taskHandlers registrar. + */ + override val jobs: PluginJobHandlerRegistrar = scopedJobHandlers + // ─── Not yet implemented ─────────────────────────────────────── override val transaction: Transaction diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt index 0a448a8..7f512bd 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -16,9 +16,11 @@ import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry import org.vibeerp.platform.security.authz.PermissionEvaluator import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar +import org.vibeerp.platform.plugins.jobs.ScopedJobHandlerRegistrar import org.vibeerp.platform.plugins.lint.PluginLinter import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar +import org.vibeerp.platform.jobs.JobHandlerRegistry import org.vibeerp.platform.workflow.PluginProcessDeployer import org.vibeerp.platform.workflow.TaskHandlerRegistry import java.nio.file.Files @@ -70,6 +72,7 @@ class VibeErpPluginManager( private val localeProvider: LocaleProvider, private val taskHandlerRegistry: TaskHandlerRegistry, private val pluginProcessDeployer: PluginProcessDeployer, + private val jobHandlerRegistry: JobHandlerRegistry, ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) @@ -281,6 +284,7 @@ class VibeErpPluginManager( pluginTranslator = pluginTranslator, sharedLocaleProvider = localeProvider, scopedTaskHandlers = ScopedTaskHandlerRegistrar(taskHandlerRegistry, pluginId), + scopedJobHandlers = ScopedJobHandlerRegistrar(jobHandlerRegistry, pluginId), ) try { vibeErpPlugin.start(context) @@ -296,6 +300,7 @@ class VibeErpPluginManager( // undeployment is unnecessary here. endpointRegistry.unregisterAll(pluginId) taskHandlerRegistry.unregisterAllByOwner(pluginId) + jobHandlerRegistry.unregisterAllByOwner(pluginId) return } @@ -326,6 +331,7 @@ class VibeErpPluginManager( started.removeAll { it.first == pluginId } endpointRegistry.unregisterAll(pluginId) taskHandlerRegistry.unregisterAllByOwner(pluginId) + jobHandlerRegistry.unregisterAllByOwner(pluginId) pluginProcessDeployer.undeployByPlugin(pluginId) } } @@ -341,6 +347,7 @@ class VibeErpPluginManager( } endpointRegistry.unregisterAll(pluginId) taskHandlerRegistry.unregisterAllByOwner(pluginId) + jobHandlerRegistry.unregisterAllByOwner(pluginId) pluginProcessDeployer.undeployByPlugin(pluginId) } started.clear() diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jobs/ScopedJobHandlerRegistrar.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jobs/ScopedJobHandlerRegistrar.kt new file mode 100644 index 0000000..5589b89 --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jobs/ScopedJobHandlerRegistrar.kt @@ -0,0 +1,34 @@ +package org.vibeerp.platform.plugins.jobs + +import org.vibeerp.api.v1.jobs.JobHandler +import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar +import org.vibeerp.platform.jobs.JobHandlerRegistry + +/** + * The per-plug-in implementation of [PluginJobHandlerRegistrar] that + * plug-ins receive through + * [org.vibeerp.api.v1.plugin.PluginContext.jobs]. + * + * Parallel to the workflow-side + * [org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar]. + * Holds a reference to the framework's host [JobHandlerRegistry] + * and a fixed `pluginId`; every [register] call tags the + * registration with the plug-in's id so the host can drop every + * handler the plug-in contributed with a single + * `JobHandlerRegistry.unregisterAllByOwner(pluginId)` call at + * plug-in stop time. + * + * Plug-ins cannot impersonate another plug-in's namespace because + * they never set the `ownerId` argument — it's baked into this + * registrar by the host when it constructs + * [org.vibeerp.platform.plugins.DefaultPluginContext]. + */ +internal class ScopedJobHandlerRegistrar( + private val hostRegistry: JobHandlerRegistry, + private val pluginId: String, +) : PluginJobHandlerRegistrar { + + override fun register(handler: JobHandler) { + hostRegistry.register(handler, ownerId = pluginId) + } +} diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt index cbd2abd..c261c3a 100644 --- a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.plugin.PluginRequest import org.vibeerp.api.v1.plugin.PluginResponse +import org.vibeerp.reference.printingshop.jobs.PlateCleanupJobHandler import org.vibeerp.reference.printingshop.workflow.CreateWorkOrderFromQuoteTaskHandler import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler import java.time.Instant @@ -288,6 +289,12 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP "registered 2 TaskHandlers: " + "${PlateApprovalTaskHandler.KEY}, ${CreateWorkOrderFromQuoteTaskHandler.KEY}", ) + + // ─── Job handlers (P1.10 follow-up — plug-in loaded JobHandlers) ── + context.jobs.register(PlateCleanupJobHandler(context)) + context.logger.info( + "registered 1 JobHandler: ${PlateCleanupJobHandler.KEY}", + ) } /** diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/jobs/PlateCleanupJobHandler.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/jobs/PlateCleanupJobHandler.kt new file mode 100644 index 0000000..405ce7a --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/jobs/PlateCleanupJobHandler.kt @@ -0,0 +1,56 @@ +package org.vibeerp.reference.printingshop.jobs + +import org.vibeerp.api.v1.jobs.JobContext +import org.vibeerp.api.v1.jobs.JobHandler +import org.vibeerp.api.v1.plugin.PluginContext + +/** + * Reference plug-in-contributed JobHandler — the executable + * acceptance test for the P1.10 follow-up "plug-in loaded + * JobHandler registration" seam. + * + * Captures the [PluginContext] via constructor so it can reach + * `context.jdbc` / `context.logger` from inside [execute], matching + * the same "handler-side plug-in context access" pattern the + * workflow-side `PlateApprovalTaskHandler` uses. + * + * **What it does.** Logs a summary of plate counts per status in + * the printing-shop schema, using `context.jdbc` to read from + * `plugin_printingshop__plate`. Zero mutation — this handler is a + * reporting-only job suitable for a nightly cron schedule. Real + * cleanup jobs (e.g. "delete DRAFT plates older than 90 days") + * would layer an `UPDATE` or `DELETE` on the same `context.jdbc` + * surface. + * + * **Key naming.** `printing_shop.plate.cleanup` — same + * underscore-in-segments convention as the workflow task keys so + * both plug-in-contributed seams look alike in the framework's + * log lines. + */ +class PlateCleanupJobHandler( + private val context: PluginContext, +) : JobHandler { + + override fun key(): String = KEY + + override fun execute(jobContext: JobContext) { + context.logger.info( + "PlateCleanupJobHandler firing corr='${jobContext.correlationId()}'", + ) + + val rows = context.jdbc.query( + "SELECT status, COUNT(*) AS c FROM plugin_printingshop__plate GROUP BY status", + ) { row -> + mapOf("status" to row.string("status"), "count" to row.int("c")) + } + + val total = rows.sumOf { (it["count"] as Int) } + context.logger.info( + "PlateCleanupJobHandler summary: total=$total byStatus=$rows", + ) + } + + companion object { + const val KEY: String = "printing_shop.plate.cleanup" + } +}