diff --git a/api/api-v1/build.gradle.kts b/api/api-v1/build.gradle.kts new file mode 100644 index 0000000..021e08d --- /dev/null +++ b/api/api-v1/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + `java-library` + `maven-publish` +} + +description = "vibe_erp Public Plug-in API v1 — the only stable contract a plug-in is allowed to depend on." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + withSourcesJar() + withJavadocJar() +} + +kotlin { + jvmToolchain(21) +} + +// api-v1 has the strictest dependency hygiene in the entire project. +// Adding ANY dependency here is effectively adding it to every plug-in +// in the ecosystem, forever, until the next major version bump. Be paranoid. +dependencies { + api(libs.kotlin.stdlib) + api(libs.jakarta.validation.api) + + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) +} + +tasks.test { + useJUnitPlatform() +} + +// We override the inherited project version (0.1.0-SNAPSHOT) with the +// independently-versioned API line, because plug-in authors track this. +version = providers.gradleProperty("vibeerp.api.version").get() + +publishing { + publications { + create("maven") { + from(components["java"]) + groupId = "org.vibeerp" + artifactId = "api-v1" + version = project.version.toString() + pom { + name.set("vibe_erp Public Plug-in API v1") + description.set("Stable, semver-governed plug-in API for the vibe_erp framework.") + url.set("https://github.com/vibeerp/vibe-erp") + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + } + } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt new file mode 100644 index 0000000..c9f0ccf --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt @@ -0,0 +1,46 @@ +package org.vibeerp.api.v1.core + +import java.util.UUID + +/** + * Strongly-typed identifier wrapping a UUID. + * + * Using `Id` instead of raw `UUID` everywhere prevents the classic + * mistake of passing a tenant id where a user id was expected — the compiler + * catches it. The wrapper is a value class so it has zero runtime overhead. + * + * @param T phantom type parameter that names what kind of entity this id refers to. + */ +@JvmInline +value class Id(val value: UUID) { + override fun toString(): String = value.toString() + + companion object { + fun of(uuid: String): Id = Id(UUID.fromString(uuid)) + fun random(): Id = Id(UUID.randomUUID()) + } +} + +/** + * A tenant identifier. + * + * Self-hosted single-customer deployments use a single tenant whose id is the + * literal string "default". Hosted multi-tenant deployments have one [TenantId] + * per customer organization. + */ +@JvmInline +value class TenantId(val value: String) { + init { + require(value.isNotBlank()) { "TenantId must not be blank" } + require(value.length <= 64) { "TenantId must be at most 64 characters" } + require(value.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { + "TenantId may only contain letters, digits, '-', or '_' (got '$value')" + } + } + + override fun toString(): String = value + + companion object { + val DEFAULT = TenantId("default") + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Money.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Money.kt new file mode 100644 index 0000000..8ddb37a --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Money.kt @@ -0,0 +1,81 @@ +package org.vibeerp.api.v1.core + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * A monetary amount in a specific ISO-4217 currency. + * + * vibe_erp NEVER uses raw `Double` or `BigDecimal` for money in api.v1, because + * that strips currency context and invites silent unit conversion bugs across + * tenants and locales. Every monetary field on every entity uses [Money]. + * + * The framework is sold worldwide, so multi-currency is non-negotiable from + * day one (CLAUDE.md guardrail #6 / i18n). + */ +data class Money( + val amount: BigDecimal, + val currency: Currency, +) : Comparable { + + init { + require(amount.scale() <= currency.defaultFractionDigits) { + "Money amount scale ${amount.scale()} exceeds currency ${currency.code} fraction digits ${currency.defaultFractionDigits}" + } + } + + operator fun plus(other: Money): Money { + require(currency == other.currency) { + "Cannot add Money in different currencies: ${currency.code} vs ${other.currency.code}" + } + return Money(amount + other.amount, currency) + } + + operator fun minus(other: Money): Money { + require(currency == other.currency) { + "Cannot subtract Money in different currencies: ${currency.code} vs ${other.currency.code}" + } + return Money(amount - other.amount, currency) + } + + operator fun times(multiplier: BigDecimal): Money = + Money(amount.multiply(multiplier).setScale(currency.defaultFractionDigits, RoundingMode.HALF_EVEN), currency) + + override fun compareTo(other: Money): Int { + require(currency == other.currency) { + "Cannot compare Money in different currencies: ${currency.code} vs ${other.currency.code}" + } + return amount.compareTo(other.amount) + } + + companion object { + fun zero(currency: Currency): Money = + Money(BigDecimal.ZERO.setScale(currency.defaultFractionDigits), currency) + } +} + +/** + * Minimal ISO-4217 currency descriptor. + * + * Kept intentionally tiny in api.v1: code + default fraction digits. The + * full currency catalog (display names, symbols, regional formatting) lives + * in the i18n layer and is not part of the stable plug-in contract. + */ +data class Currency(val code: String, val defaultFractionDigits: Int) { + init { + require(code.length == 3 && code.all { it.isUpperCase() }) { + "Currency code must be 3 uppercase letters (got '$code')" + } + require(defaultFractionDigits in 0..6) { + "defaultFractionDigits out of range: $defaultFractionDigits" + } + } + + companion object { + val USD = Currency("USD", 2) + val EUR = Currency("EUR", 2) + val CNY = Currency("CNY", 2) + val JPY = Currency("JPY", 0) + val GBP = Currency("GBP", 2) + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Quantity.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Quantity.kt new file mode 100644 index 0000000..bdf64ec --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Quantity.kt @@ -0,0 +1,53 @@ +package org.vibeerp.api.v1.core + +import java.math.BigDecimal + +/** + * A quantity in a specific unit of measure. + * + * Like [Money], quantities carry their unit so the compiler and the runtime + * can refuse to add 5 kg to 5 m. The full unit catalog (with conversion + * factors and locale-aware display) lives in pbc-catalog and is not part of + * the stable api.v1 surface — only the [UnitOfMeasure] descriptor is. + */ +data class Quantity( + val amount: BigDecimal, + val unit: UnitOfMeasure, +) : Comparable { + + operator fun plus(other: Quantity): Quantity { + require(unit == other.unit) { + "Cannot add Quantity in different units: ${unit.code} vs ${other.unit.code}. " + + "Convert through pbc-catalog before arithmetic." + } + return Quantity(amount + other.amount, unit) + } + + operator fun minus(other: Quantity): Quantity { + require(unit == other.unit) { + "Cannot subtract Quantity in different units: ${unit.code} vs ${other.unit.code}" + } + return Quantity(amount - other.amount, unit) + } + + override fun compareTo(other: Quantity): Int { + require(unit == other.unit) { + "Cannot compare Quantity in different units: ${unit.code} vs ${other.unit.code}" + } + return amount.compareTo(other.amount) + } +} + +/** + * Unit of measure descriptor. + * + * `code` is a stable identifier (e.g. "kg", "m", "ea", "sheet"). Display + * names and conversions are NOT part of api.v1 — they live in pbc-catalog. + */ +data class UnitOfMeasure(val code: String) { + init { + require(code.isNotBlank() && code.length <= 16) { + "UnitOfMeasure code must be 1-16 characters (got '$code')" + } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Result.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Result.kt new file mode 100644 index 0000000..cd4fccb --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Result.kt @@ -0,0 +1,39 @@ +package org.vibeerp.api.v1.core + +/** + * A typed success-or-failure value used across the api.v1 surface. + * + * vibe_erp prefers explicit Result types in plug-in-facing APIs over thrown + * exceptions for *expected* failures (validation errors, permission denials, + * not-found, optimistic-locking conflicts). Exceptions remain appropriate for + * unexpected, infrastructural failures (the database is down, etc.). + * + * Why: plug-ins are loaded into a host process and a thrown exception that + * crosses a classloader boundary can be very hard to handle correctly. A typed + * Result keeps the contract honest. + */ +sealed interface Result { + data class Ok(val value: T) : Result + data class Err(val error: E) : Result + + fun isOk(): Boolean = this is Ok + fun isErr(): Boolean = this is Err + + fun valueOrNull(): T? = (this as? Ok)?.value + fun errorOrNull(): E? = (this as? Err)?.error + + companion object { + fun ok(value: T): Result = Ok(value) + fun err(error: E): Result = Err(error) + } +} + +inline fun Result.map(transform: (T) -> R): Result = when (this) { + is Result.Ok -> Result.Ok(transform(value)) + is Result.Err -> this +} + +inline fun Result.flatMap(transform: (T) -> Result): Result = when (this) { + is Result.Ok -> transform(value) + is Result.Err -> this +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/AuditedEntity.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/AuditedEntity.kt new file mode 100644 index 0000000..70f24fe --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/AuditedEntity.kt @@ -0,0 +1,37 @@ +package org.vibeerp.api.v1.entity + +import org.vibeerp.api.v1.security.PrincipalId +import java.time.Instant + +/** + * An [Entity] that carries who/when audit columns automatically maintained by + * the platform's JPA listener. + * + * Why this exists as a *separate* interface rather than fields on [Entity]: + * - Some entities (config rows, lookup tables, immutable event records) do + * not need an updated-at column and forcing one on them invites lying + * timestamps. + * - It gives the platform a typed hook to know "this entity wants the audit + * listener" without reflection-scanning every class. + * - It documents in the type system that the four columns are populated by + * the framework, not by the plug-in. Plug-ins should treat the setters + * as read-only at the application level — the values are filled in at + * flush time from the current [org.vibeerp.api.v1.security.Principal]. + * + * The four columns mirror what every serious ERP records on every business + * row and what GDPR/SOX-style audits expect to find. + */ +interface AuditedEntity : Entity { + + /** When this row was first inserted. UTC. Set once, never updated. */ + val createdAt: Instant + + /** Principal that performed the insert. */ + val createdBy: PrincipalId + + /** When this row was last updated. UTC. Equal to [createdAt] until first update. */ + val updatedAt: Instant + + /** Principal that performed the most recent update. */ + val updatedBy: PrincipalId +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/CustomField.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/CustomField.kt new file mode 100644 index 0000000..9ab2191 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/CustomField.kt @@ -0,0 +1,50 @@ +package org.vibeerp.api.v1.entity + +/** + * Declarative description of a custom field added to an existing entity. + * + * Custom fields are how the framework satisfies guardrail #1 ("core stays + * domain-agnostic") in practice: a printing shop that needs a `plate_gauge` + * column on the catalog item entity does not patch `pbc-catalog`. They add + * a [CustomField] row through the no-code UI (Tier 1) or through a plug-in + * manifest (Tier 2), and the JSONB `ext` column on the catalog item table + * starts carrying the value the same instant. + * + * The form builder, list view designer, OpenAPI generator, AI agent function + * catalog, and DSAR/erasure jobs all consume this descriptor. There is + * intentionally no parallel source of truth. + * + * @property key Stable identifier for this field within [targetEntity]. Must be + * stable across upgrades because it is the JSON key inside the `ext` column. + * Convention: `snake_case`, prefixed by the owning plug-in or PBC, e.g. + * `printingshop_plate_gauge`. + * @property targetEntity Name of the entity this field is attached to. Must + * be discoverable through [EntityRegistry] at registration time. + * @property type The field's data type — see [FieldType]. + * @property required Whether the value must be present on insert/update. + * @property pii Whether this field contains personally identifiable information. + * Drives automatic DSAR exports and erasure jobs (architecture spec section 8, + * "Data sovereignty"). Setting this wrong is a compliance bug, not a styling + * choice — when in doubt, mark it `true`. + * @property labelTranslations Locale code → human-readable label. The framework + * refuses to render a custom field that has no label for the current locale + * *and* no entry for the configured fallback locale; this prevents the + * classic "key string leaked to the UI" failure mode. + */ +data class CustomField( + val key: String, + val targetEntity: String, + val type: FieldType, + val required: Boolean = false, + val pii: Boolean = false, + val labelTranslations: Map = emptyMap(), +) { + init { + require(key.isNotBlank()) { "CustomField key must not be blank" } + require(key.length <= 64) { "CustomField key must be at most 64 characters (got '$key')" } + require(key.all { it.isLetterOrDigit() || it == '_' }) { + "CustomField key may only contain letters, digits, and '_' (got '$key')" + } + require(targetEntity.isNotBlank()) { "CustomField targetEntity must not be blank" } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/Entity.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/Entity.kt new file mode 100644 index 0000000..0e369b8 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/Entity.kt @@ -0,0 +1,50 @@ +package org.vibeerp.api.v1.entity + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.core.TenantId + +/** + * Marker interface for every domain entity that lives in the vibe_erp data model. + * + * Why this exists at all: + * - It enforces, at the type level, that every entity carries a tenant id. + * Multi-tenancy is an architectural guardrail (CLAUDE.md #5) and the cheapest + * place to enforce it is the type system. A repository signature like + * `Repository` cannot be instantiated for a type that "forgot" + * its tenant id. + * - It gives the runtime a single hook for tenant filtering, JSONB `ext` + * handling, and audit interception without each PBC reinventing it. + * - It is intentionally minimal: no equality, no hashCode, no lifecycle + * callbacks. Those are concerns of `platform.persistence`, not the public + * contract. Adding them later is a breaking change; leaving them out is not. + * + * Plug-ins implement this on their own data classes. They never see the + * underlying JPA `@Entity` annotation — that lives in the platform's internal + * mapping layer. + * + * ``` + * data class Plate( + * override val id: Id, + * override val tenantId: TenantId, + * val gauge: Quantity, + * ) : Entity + * ``` + */ +interface Entity { + /** + * The strongly-typed identifier for this entity. Using `Id<*>` here (rather + * than `Id`) keeps the marker contravariant-friendly so polymorphic + * collections of `Entity` can be passed across api.v1 boundaries without + * generic gymnastics. Concrete implementations should narrow the type + * parameter to themselves. + */ + val id: Id<*> + + /** + * The tenant that owns this row. Required on every business entity, even in + * single-tenant self-hosted deployments (where the value is + * [TenantId.DEFAULT]) — there is intentionally one code path for both + * deployment shapes. + */ + val tenantId: TenantId +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/EntityRegistry.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/EntityRegistry.kt new file mode 100644 index 0000000..a1df4d8 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/EntityRegistry.kt @@ -0,0 +1,66 @@ +package org.vibeerp.api.v1.entity + +/** + * Runtime catalog of every entity the framework knows about — core PBC + * entities, plug-in entities, and Tier 1 user-defined entities. + * + * Why this is part of api.v1: + * - Plug-ins need to introspect entities at runtime to render generic UIs, + * drive the OpenAPI generator, and resolve [FieldType.Reference] targets, + * *without* depending on the concrete PBC that owns each entity (which + * would violate guardrail #9, "PBC boundaries are sacred"). + * - It is the seam through which Tier 1 user-defined entities and Tier 2 + * plug-in entities look identical to consumers. Neither caller cares + * where an entity descriptor came from. + * + * The registry is read-only from the plug-in's perspective. Registration + * happens during plug-in startup via [org.vibeerp.api.v1.plugin.PluginContext] + * helpers that the platform implements internally. + */ +interface EntityRegistry { + + /** + * Look up an entity descriptor by its registered name. + * + * @return the descriptor, or `null` if no entity with that name exists. + * Plug-ins should not throw on a miss — entities can be uninstalled + * while a workflow that referenced them is mid-flight. + */ + fun describe(entityName: String): EntityDescriptor? + + /** + * Snapshot of every currently-registered entity name. Default + * implementation returns an empty set so adding new lookup methods later + * (e.g. filtered by source) is non-breaking for existing host + * implementations. + */ + fun entityNames(): Set = emptySet() +} + +/** + * Lightweight description of an entity, returned by [EntityRegistry.describe]. + * + * Kept intentionally small. The full mapping (table name, column types, + * Hibernate metadata) lives in the platform internals; api.v1 exposes only + * what plug-ins legitimately need to render generic UIs and validate input. + * + * @property name Stable, globally unique entity name, e.g. `catalog.item` or + * `plugin_printingshop.plate_spec`. + * @property pluginId The plug-in id that owns this entity, or `null` if it + * belongs to a core PBC. + * @property customFields Custom fields currently declared against this entity + * for the calling tenant. Note: this list is *tenant-scoped* — two tenants + * on the same host can see different custom fields on the same entity. + * @property auditable Whether the entity implements [AuditedEntity] and + * therefore carries the four audit columns. + */ +data class EntityDescriptor( + val name: String, + val pluginId: String? = null, + val customFields: List = emptyList(), + val auditable: Boolean = false, +) { + init { + require(name.isNotBlank()) { "EntityDescriptor name must not be blank" } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/FieldType.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/FieldType.kt new file mode 100644 index 0000000..9914e8a --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/FieldType.kt @@ -0,0 +1,104 @@ +package org.vibeerp.api.v1.entity + +/** + * The closed set of field types that vibe_erp's metadata layer can describe. + * + * Why this is a sealed interface and not an enum: + * - Several types carry their own configuration (max length on STRING, + * precision on DECIMAL, target entity on REFERENCE, allowed values on + * ENUM). An enum cannot do that without a parallel `Map` + * table that the type system cannot keep in sync. + * - It is closed deliberately. Adding a new field type is an api.v1 change + * requiring a release; allowing arbitrary new field types in user metadata + * would defeat the form designer, the OpenAPI generator, the AI function + * catalog, and the PII/DSAR pipeline, all of which switch on this set. + * + * Field types are used in two places: + * 1. [CustomField] declarations, where a key user adds a column to an + * existing entity through the no-code UI. + * 2. Plug-in entity descriptors registered through [EntityRegistry], so the + * same UI machinery (form builder, list view, OpenAPI) works on plug-in + * entities without the plug-in shipping its own React code. + * + * Storage shape: + * - Custom-field values live in the JSONB `ext` column on the host entity's + * table (architecture spec section 8). The field type drives JSON encoding, + * GIN-index strategy, validation, and OpenAPI schema generation. + */ +sealed interface FieldType { + + /** Free text. [maxLength] caps storage and form input length. */ + data class String(val maxLength: Int = 255) : FieldType { + init { + require(maxLength in 1..1_048_576) { "STRING maxLength out of range: $maxLength" } + } + } + + /** 64-bit signed integer. */ + data object Integer : FieldType + + /** + * Fixed-point decimal stored as a JSON string to preserve scale. + * [precision] is total digits, [scale] is digits after the decimal point. + */ + data class Decimal(val precision: Int = 19, val scale: Int = 4) : FieldType { + init { + require(precision in 1..38) { "DECIMAL precision out of range: $precision" } + require(scale in 0..precision) { "DECIMAL scale out of range: $scale" } + } + } + + /** Boolean. */ + data object Boolean : FieldType + + /** Calendar date with no time-of-day and no zone. */ + data object Date : FieldType + + /** Instant on the timeline (stored UTC, rendered in the user's locale). */ + data object DateTime : FieldType + + /** RFC-4122 UUID. Distinct from [Reference] — a free-floating identifier. */ + data object Uuid : FieldType + + /** + * A monetary value. The currency is part of the *value*, not the field + * descriptor — see [org.vibeerp.api.v1.core.Money]. This means a single + * `total_amount` field can legitimately hold mixed-currency rows. + */ + data object Money : FieldType + + /** A quantity with a unit of measure attached to the value. */ + data object Quantity : FieldType + + /** + * A foreign-key-style pointer to another entity by name. + * + * The reference is resolved by [EntityRegistry] at runtime, which is what + * lets a Tier 1 user create a custom field that points at a plug-in + * entity without anyone writing JPA mappings. + */ + data class Reference(val targetEntity: kotlin.String) : FieldType { + init { + require(targetEntity.isNotBlank()) { "REFERENCE targetEntity must not be blank" } + } + } + + /** + * A closed set of allowed string values. The values themselves are stored + * as plain strings; their human-readable labels live in the i18n bundles + * keyed by `..`. + */ + data class Enum(val allowedValues: List) : FieldType { + init { + require(allowedValues.isNotEmpty()) { "ENUM must declare at least one allowed value" } + require(allowedValues.toSet().size == allowedValues.size) { "ENUM allowedValues must be unique" } + } + } + + /** + * Arbitrary structured JSON. Use sparingly — JSON fields are opaque to + * the form builder, the list view, and the OpenAPI schema generator. + * Prefer a structured type when one fits. + */ + data object Json : FieldType +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt new file mode 100644 index 0000000..8285e78 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt @@ -0,0 +1,62 @@ +package org.vibeerp.api.v1.event + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.core.TenantId +import java.time.Instant + +/** + * Base contract for every domain event published on the vibe_erp event bus. + * + * Why a sealed-style typed contract instead of "publish any object": + * - PBC boundaries are sacred (CLAUDE.md guardrail #9). PBCs talk to each + * other only through events or through `api.v1.ext.` interfaces. If + * events were untyped, every consumer would have to know every producer's + * class, which is exactly the coupling we are trying to avoid. + * - The standard envelope (`eventId`, `tenantId`, `occurredAt`, + * `aggregateType`, `aggregateId`) is what the platform's outbox table, + * audit log, and future Kafka/NATS bridge all rely on. Plug-ins that + * forget any of these fields cannot misroute or replay events. + * + * Note that this is `interface`, not `sealed interface`: plug-ins must be + * free to *extend* the hierarchy with their own concrete event classes. + * "Sealed" here means "every plug-in event must extend this sealed envelope", + * not "the set of envelope shapes is closed". A truly closed hierarchy would + * make plug-ins impossible. + * + * ``` + * data class PlateApproved( + * override val eventId: Id = Id.random(), + * override val tenantId: TenantId, + * override val occurredAt: Instant = Instant.now(), + * val plateId: Id<*>, + * ) : DomainEvent { + * override val aggregateType = "printingshop.plate" + * override val aggregateId = plateId.toString() + * } + * ``` + */ +interface DomainEvent { + + /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */ + val eventId: Id + + /** Tenant the event belongs to. Carried explicitly because background consumers run outside any HTTP request. */ + val tenantId: TenantId + + /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */ + val occurredAt: Instant + + /** + * Logical name of the aggregate this event is about, e.g. + * `orders_sales.order` or `plugin_printingshop.plate`. Used for routing, + * audit views, and the future event-streaming bridge. + */ + val aggregateType: String + + /** + * String form of the aggregate's id. String (not [Id]) because some + * aggregates are keyed by composite or natural keys and the bus must + * route them all. + */ + val aggregateId: String +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt new file mode 100644 index 0000000..bcae59c --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt @@ -0,0 +1,41 @@ +package org.vibeerp.api.v1.event + +/** + * Publish/subscribe contract for domain events. + * + * The host's implementation is backed by an in-process bus plus a Postgres + * **outbox** table (architecture spec section 9, "Cross-cutting concerns"). + * The outbox is what makes "modular monolith now, splittable later" real: + * the day the framework grows a Kafka or NATS bridge, the existing + * publish/subscribe code in every PBC and plug-in keeps working unchanged. + * + * Why this is the only way plug-ins emit events: + * - The publish path goes through the platform so it can attach the current + * tenant, principal, and request id automatically; a plug-in that bypasses + * [EventBus] would lose all of that and break the audit trail. + * - The subscribe path is the platform's only place to track listener + * registrations per plug-in classloader, so unloading a plug-in cleanly + * removes its listeners. + * + * Both methods are deliberately non-suspending and Java-friendly (`Class` + * over `KClass`) so the contract is callable from any JVM language a + * plug-in author chooses. + */ +interface EventBus { + + /** + * Publish an event. The bus is responsible for delivering it to every + * matching listener and for persisting an outbox row inside the current + * transaction (so a publish followed by a rollback never escapes). + */ + fun publish(event: DomainEvent) + + /** + * Register a listener for events of [eventType] (and its subtypes). + * + * The listener stays registered until the plug-in is stopped, at which + * point the platform tears it down automatically — plug-ins do not + * need (and should not have) a manual `unsubscribe` method. + */ + fun subscribe(eventType: Class, listener: EventListener) +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventListener.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventListener.kt new file mode 100644 index 0000000..ae6a8bd --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventListener.kt @@ -0,0 +1,25 @@ +package org.vibeerp.api.v1.event + +/** + * Single-method listener for domain events. + * + * Declared as `fun interface` so plug-ins can register listeners with a Kotlin + * lambda or a method reference, without writing an anonymous-class boilerplate + * shell: + * + * ``` + * eventBus.subscribe(PlateApproved::class.java) { event -> + * logger.info("Plate ${event.plateId} approved") + * } + * ``` + * + * Listeners run on the thread the bus chooses, which may be the publisher's + * thread (in-process synchronous), a worker thread (in-process async), or a + * dedicated outbox-drain thread when reliability is required. Implementations + * MUST be thread-safe and MUST NOT assume the publisher's transaction is + * still open — if a listener needs its own transaction, it must request one + * through [org.vibeerp.api.v1.persistence.Transaction]. + */ +fun interface EventListener { + fun handle(event: E) +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt new file mode 100644 index 0000000..6f50d08 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt @@ -0,0 +1,65 @@ +package org.vibeerp.api.v1.ext.identity + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.core.TenantId + +/** + * Cross-PBC facade for the identity bounded context. + * + * **This file is the proof that PBCs talk to each other through `api.v1`, + * not directly.** Guardrail #9 ("PBC boundaries are sacred") is not just a + * convention; it is enforced by the Gradle build, which forbids + * `pbc-orders-sales` from declaring `pbc-identity` as a dependency. The only + * way `pbc-orders-sales` (or any plug-in) can ask "who is the user with this + * username?" is by injecting [IdentityApi] — an interface declared here, in + * api.v1, and implemented by `pbc-identity` at runtime. + * + * If [IdentityApi] grows a method that leaks identity-internal types, the + * abstraction has failed and the method must be reshaped. The whole point of + * this package is to expose intent (`UserRef`) without exposing the storage + * model (`IdentityUserEntity`, `IdentityUserRow`, etc.). + * + * Future PBCs will get sibling packages (`api.v1.ext.catalog`, + * `api.v1.ext.inventory`, …) following the same pattern. + */ +interface IdentityApi { + + /** + * Look up a user by their unique username within the current tenant. + * Returns `null` when no such user exists or when the calling principal + * lacks permission to read user records. + */ + fun findUserByUsername(username: String): UserRef? + + /** + * Look up a user by id. The id is `Id<*>` rather than `Id` so + * callers do not have to know the exact entity type — they pass the id + * they have, and the implementation does the matching internally. + */ + fun findUserById(id: Id<*>): UserRef? +} + +/** + * Minimal, safe-to-publish view of a user. + * + * Notably absent: password hash, MFA secrets, last login IP, anything from + * the `identity__user_security` table. Anything that does not appear here + * cannot be observed cross-PBC, and that is intentional — adding a field is + * a deliberate api.v1 change and triggers a security review. + * + * @property id Stable user id. + * @property username Login handle. Unique within [tenantId]. + * @property tenantId Owning tenant. + * @property displayName Human-readable name for UI rendering. Falls back to + * [username] when the user has not set one. + * @property enabled Whether the account is active. A disabled user can still + * appear as an audit-column foreign key, which is why this field exists at + * all — callers must check it before treating the user as a current actor. + */ +data class UserRef( + val id: Id, + val username: String, + val tenantId: TenantId, + val displayName: String, + val enabled: Boolean, +) diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/form/FormSchema.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/form/FormSchema.kt new file mode 100644 index 0000000..9a3d15c --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/form/FormSchema.kt @@ -0,0 +1,41 @@ +package org.vibeerp.api.v1.form + +/** + * A form definition shipped by a plug-in or stored in the metadata table. + * + * Why two strings instead of a structured Kotlin model: + * - The form designer in the web UI works in JSON Schema + UI Schema (the + * same shape used by Frappe Doctype, ERPNext form layouts, and the + * `@rjsf/core` React renderer). Re-modeling that as Kotlin classes would + * create a parallel source of truth that drifts the moment a renderer + * feature is added. + * - api.v1 must not depend on a specific JSON parser (Jackson is forbidden). + * Holding the JSON as opaque strings means the platform parses it once, + * inside the platform layer, and plug-ins never need a JSON dependency + * just to ship a form. + * - The richer typed wrapper API lands in v0.2 of the api.v1 line as a + * purely additive layer. Until then, this is the smallest contract that + * lets a plug-in ship a working form. + * + * @property jsonSchema A JSON Schema (draft 2020-12) document describing + * the data shape of the form. The host validates user input against this + * schema before passing it to the plug-in. + * @property uiSchema A UI Schema document (the `@rjsf/core` convention) + * describing widget choices, ordering, and layout hints. May be empty + * string when the default rendering is acceptable. + * @property version Monotonically increasing schema version. The host + * stores submitted form values tagged with the version they were captured + * under so a plug-in upgrade does not retroactively invalidate old + * records. Bump this when the [jsonSchema] changes shape, not when the + * [uiSchema] is merely restyled. + */ +data class FormSchema( + val jsonSchema: String, + val uiSchema: String, + val version: Int, +) { + init { + require(jsonSchema.isNotBlank()) { "FormSchema jsonSchema must not be blank" } + require(version >= 1) { "FormSchema version must be >= 1 (got $version)" } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/PluginEndpoint.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/PluginEndpoint.kt new file mode 100644 index 0000000..2debade --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/PluginEndpoint.kt @@ -0,0 +1,43 @@ +package org.vibeerp.api.v1.http + +/** + * Marks a class as a plug-in HTTP controller. + * + * Why this is a vibe_erp-owned annotation rather than `@RestController`: + * - api.v1 must not leak Spring types to plug-ins (CLAUDE.md guardrail #10). + * A plug-in that imports `org.springframework.web.bind.annotation.RestController` + * is grade-D and rejected at install time. + * - The host scans for `@PluginEndpoint` inside each plug-in's child Spring + * context and mounts the controller automatically under the prefix + * `/api/v1/plugins/` + [basePath]. The plug-in author never + * chooses the absolute URL — that prevents two plug-ins from colliding on + * the same path and gives the OpenAPI generator one source of truth. + * - The host applies authentication, tenant resolution, locale resolution, + * request id propagation, structured logging, and the `RequestContext` + * binding to every method on a `@PluginEndpoint` class, so plug-ins + * inherit all of that without any per-controller setup. + * + * The actual HTTP method bindings (GET/POST/etc.), request body parsing, and + * response serialization are still expressed using a small set of api.v1 + * annotations to be added in subsequent releases. Until then, the host + * delegates to standard JAX-RS / Spring Web binding inside the platform + * layer; the annotation surface visible to plug-ins is intentionally minimal + * in v1.0 so it can grow deliberately. + * + * ``` + * @PluginEndpoint(basePath = "/plates") + * class PlateController(private val ctx: RequestContext) { + * // mounted at /api/v1/plugins/printingshop/plates + * } + * ``` + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class PluginEndpoint( + /** + * Path under the plug-in's namespace, beginning with `/`. The full URL + * is `/api/v1/plugins/`. + */ + val basePath: String, +) diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt new file mode 100644 index 0000000..4bed020 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt @@ -0,0 +1,46 @@ +package org.vibeerp.api.v1.http + +import org.vibeerp.api.v1.core.TenantId +import org.vibeerp.api.v1.security.Principal +import java.util.Locale + +/** + * Per-request context, available by injection inside any class annotated with + * [PluginEndpoint]. + * + * Why an explicit context object instead of "scattered ThreadLocals": + * - Plug-ins live in their own classloader, so a static `ThreadLocal` lookup + * inside api.v1 would have to bridge classloaders to find the host's + * state. An injected context is cleaner, testable, and forces the + * plug-in author to declare a dependency on the values they use. + * - It is the single answer to "who, where, when, which language, which + * request" that every other api.v1 surface (permission check, translator, + * repository, event bus) consults under the covers. + * + * The host provides one [RequestContext] per HTTP request and per workflow + * task execution. Background jobs receive a context whose [principal] is a + * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is + * the job execution id. + */ +interface RequestContext { + + /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */ + fun principal(): Principal + + /** + * The tenant the request is operating in. Always equal to + * `principal().tenantId` in normal flows, but separated for clarity in + * "act as" / impersonation scenarios where the two could legitimately + * differ. + */ + fun tenantId(): TenantId + + /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */ + fun locale(): Locale + + /** + * Correlation id for this request, propagated to logs, downstream calls, + * and emitted events. Stable across the whole request lifecycle. + */ + fun requestId(): String +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/LocaleProvider.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/LocaleProvider.kt new file mode 100644 index 0000000..a1a8af0 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/LocaleProvider.kt @@ -0,0 +1,27 @@ +package org.vibeerp.api.v1.i18n + +import java.util.Locale + +/** + * Resolves the locale of the current operation. + * + * Why a separate interface from [Translator]: + * - The decision "what locale am I in right now?" is the host's + * responsibility, not the plug-in's. The host walks a precedence chain + * (HTTP `Accept-Language` → user preference → tenant default → system + * default) on every request and on every background job, and a plug-in + * must NEVER reach into HTTP headers itself — it would break for + * non-HTTP callers (workflow tasks, scheduled jobs, MCP agents). + * - Splitting it from [Translator] keeps the translator pure and testable + * (you can pass any [Locale] to `translate`), while [LocaleProvider] is + * the impure side that knows about the ambient request. + * + * Outside of an HTTP request (background jobs, workflow tasks, system + * principals), the host returns the tenant's default locale, then the + * platform default. There is always *some* locale; this method never throws. + */ +interface LocaleProvider { + + /** The locale to use for the current operation. Never `null`. */ + fun current(): Locale +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/MessageKey.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/MessageKey.kt new file mode 100644 index 0000000..3495c78 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/MessageKey.kt @@ -0,0 +1,35 @@ +package org.vibeerp.api.v1.i18n + +/** + * Type-safe wrapper around an i18n message key. + * + * Why a value class instead of "just a String": + * - Translator method signatures take `MessageKey`, not `String`, so the + * compiler refuses to pass a raw user-facing English sentence into the + * translator. That single rule eliminates the most common i18n bug: + * "we forgot to translate this one place". + * - Zero runtime overhead — `@JvmInline` erases to a `String` at runtime. + * + * Naming convention is `..`. Examples: + * - `identity.user.created` + * - `catalog.item.duplicate_sku` + * - `printingshop.plate.approval_required` + * + * The convention is enforced by [init] because once a key is shipped to a + * tenant's translation overrides table, renaming it costs everyone money. + */ +@JvmInline +value class MessageKey(val key: String) { + init { + require(key.isNotBlank()) { "MessageKey must not be blank" } + require(KEY_REGEX.matches(key)) { + "MessageKey must look like '..' (got '$key')" + } + } + + override fun toString(): String = key + + companion object { + private val KEY_REGEX = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){2,}$") + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/Translator.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/Translator.kt new file mode 100644 index 0000000..ee32f3b --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/Translator.kt @@ -0,0 +1,42 @@ +package org.vibeerp.api.v1.i18n + +import java.util.Locale + +/** + * Plug-in-facing translation lookup. + * + * Why this is a single small interface and not a fluent builder: + * - i18n is a guardrail (CLAUDE.md #6). The interface a plug-in author sees + * must be tiny and impossible to use wrong; otherwise plug-ins will hard- + * code English strings "just for now" and never come back. + * - The host implements this with ICU4J `MessageFormat` so plurals, gender, + * number/date/currency formatting all work without the plug-in knowing. + * - The lookup goes through the metadata translation overrides table first, + * then the plug-in's own bundles, then the platform's bundles, then the + * fallback locale, then the key itself. None of that is the plug-in's + * problem. + * + * ``` + * translator.translate( + * MessageKey("printingshop.plate.approved"), + * localeProvider.current(), + * mapOf("plateId" to plate.id, "user" to user.username), + * ) + * ``` + */ +interface Translator { + + /** + * Resolve a message in the given locale, substituting the named arguments. + * + * @param key the message key to look up. + * @param locale the locale to render in. Use [LocaleProvider.current] to + * pick up the request locale automatically. + * @param args named arguments referenced from the message text using ICU + * MessageFormat placeholders, e.g. `{user}`, `{count, plural, ...}`. + * @return the translated string. NEVER `null` — when nothing matches, the + * host returns the key itself so the failure is visible in the UI rather + * than silently swallowed. + */ + fun translate(key: MessageKey, locale: Locale, args: Map = emptyMap()): String +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/package-info.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/package-info.kt new file mode 100644 index 0000000..2cdc3c3 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/package-info.kt @@ -0,0 +1,31 @@ +/** + * vibe_erp Public Plug-in API v1. + * + * THE STABILITY CONTRACT + * ---------------------- + * Everything declared under [org.vibeerp.api.v1] is the public, semver-governed + * surface of the vibe_erp framework. Plug-in authors depend on this package and + * only this package. + * + * Within the 1.x release line: + * • Adding new types is allowed. + * • Adding new methods to existing interfaces is allowed ONLY when a default + * implementation is provided so existing plug-ins continue to compile and load. + * • Renaming, removing, or changing the observable behavior of any symbol is + * a breaking change and requires a major-version bump (api.v2). + * + * Across a major version, the previous major (api.v1) ships side-by-side with + * the new major (api.v2) for at least one major release window so plug-in + * authors have a real migration path. + * + * WHAT IS NOT IN HERE + * ------------------- + * Anything not in [org.vibeerp.api.v1] — including everything in + * `org.vibeerp.platform.*` and `org.vibeerp.pbc.*` — is INTERNAL. Plug-ins + * that import internal classes are graded "D" (unsupported) and are rejected + * by the plug-in linter at install time. + * + * See `docs/superpowers/specs/2026-04-07-vibe-erp-architecture-design.md` + * sections 4 and 6 for the full extensibility model and api.v1 surface plan. + */ +package org.vibeerp.api.v1 diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Page.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Page.kt new file mode 100644 index 0000000..64b6b03 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Page.kt @@ -0,0 +1,35 @@ +package org.vibeerp.api.v1.persistence + +/** + * One page of results returned from [Repository.findAll]. + * + * Why a dedicated type rather than `Pair, Long>`: + * - Named fields make the wire format and the AI-agent function catalog + * self-describing. "What does the second element of the pair mean?" is + * the kind of question that makes plug-in authors miserable. + * - Adding fields later (e.g. a continuation token for keyset pagination) + * is non-breaking. Adding a third tuple element is. + * + * `totalElements` is intentionally `Long` because some PBCs (audit log, + * inventory movements) routinely cross the 32-bit row count. + */ +data class Page( + val content: List, + val pageNumber: Int, + val pageSize: Int, + val totalElements: Long, +) { + init { + require(pageNumber >= 0) { "Page pageNumber must be >= 0 (got $pageNumber)" } + require(pageSize >= 1) { "Page pageSize must be >= 1 (got $pageSize)" } + require(totalElements >= 0) { "Page totalElements must be >= 0 (got $totalElements)" } + } + + /** Number of pages, given [pageSize] and [totalElements]. */ + val totalPages: Int + get() = if (totalElements == 0L) 0 else (((totalElements - 1) / pageSize) + 1).toInt() + + /** Whether at least one more page exists after this one. */ + val hasNext: Boolean + get() = (pageNumber + 1) < totalPages +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Query.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Query.kt new file mode 100644 index 0000000..6d16e11 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Query.kt @@ -0,0 +1,92 @@ +package org.vibeerp.api.v1.persistence + +/** + * Declarative query model used by [Repository.findAll]. + * + * Why a structured query object instead of "Spring Data Specification" or a + * raw string DSL: + * - The whole api.v1 surface is forbidden from leaking Spring types + * (CLAUDE.md guardrail #10). Spring Data's `Specification` is out. + * - A typed model is what makes the OpenAPI generator and the AI agent + * function catalog possible: every supported filter shape is enumerable. + * - It can be serialized and shipped over the wire, which is how the + * cross-process query path will work when the framework eventually grows + * a remote-PBC mode. + * + * The model is intentionally small — equality, ordering, set membership, a + * SQL `LIKE`, null checks, and boolean composition. Anything more exotic + * (full-text search, geo, vector) belongs in a dedicated capability behind a + * separate api.v1 interface, not bolted onto the generic repository. + */ +data class Query( + val filters: List = emptyList(), + val sort: List = emptyList(), + val pageRequest: PageRequest = PageRequest.FIRST, +) + +/** + * The closed set of supported filter shapes. + * + * Sealed so the platform can pattern-match exhaustively when translating to + * SQL, and so adding a new shape is a deliberate api.v1 change. + */ +sealed interface Filter { + + /** Field equals value. `value` may be `null` to match SQL `IS NULL`. */ + data class Eq(val field: String, val value: Any?) : Filter + + /** Field does not equal value. */ + data class Ne(val field: String, val value: Any?) : Filter + + /** Field strictly less than value. */ + data class Lt(val field: String, val value: Any) : Filter + + /** Field strictly greater than value. */ + data class Gt(val field: String, val value: Any) : Filter + + /** Field value is one of [values]. Empty list matches nothing. */ + data class In(val field: String, val values: List) : Filter + + /** + * SQL `LIKE` against [pattern]. The pattern uses SQL wildcards (`%`, `_`) + * unescaped — callers are responsible for escaping user input. + */ + data class Like(val field: String, val pattern: String) : Filter + + /** Field is `NULL`. */ + data class IsNull(val field: String) : Filter + + /** Logical conjunction. Empty list is treated as "always true". */ + data class And(val operands: List) : Filter + + /** Logical disjunction. Empty list is treated as "always false". */ + data class Or(val operands: List) : Filter + + /** Logical negation. */ + data class Not(val operand: Filter) : Filter +} + +/** + * Sort directive. Stable across pages — the platform will append a tie-break + * on the primary key so pagination is deterministic. + */ +data class SortClause(val field: String, val direction: SortDirection = SortDirection.ASC) + +/** Sort direction. Two options, no `RANDOM` — randomness is a separate concern. */ +enum class SortDirection { ASC, DESC } + +/** + * Page coordinates. Zero-indexed for consistency with the rest of the JVM + * ecosystem (Spring Data, Hibernate). Maximum page size is enforced by the + * platform, not here, because the limit may differ per deployment. + */ +data class PageRequest(val page: Int, val size: Int) { + init { + require(page >= 0) { "PageRequest page must be >= 0 (got $page)" } + require(size in 1..10_000) { "PageRequest size must be 1..10000 (got $size)" } + } + + companion object { + val FIRST = PageRequest(page = 0, size = 50) + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt new file mode 100644 index 0000000..b4738e2 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt @@ -0,0 +1,54 @@ +package org.vibeerp.api.v1.persistence + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.entity.Entity + +/** + * Generic, tenant-scoped repository contract for any [Entity]. + * + * Why this lives in api.v1: + * - Plug-ins must be able to persist their own entities without depending on + * Spring Data, JPA, or any platform internal class. The platform binds this + * interface to a Hibernate-backed implementation per entity at startup. + * - It is the cleanest place to enforce the tenant guarantee: **none of these + * methods accept a `tenantId`**. The tenant is resolved from the ambient + * [org.vibeerp.api.v1.http.RequestContext] (or, in background jobs, from the + * job's tenant binding) by the platform. A plug-in cannot read or write + * cross-tenant data through this contract — that is the whole point. + * - It is generic on `T : Entity` so the type system rejects "wrong-entity" + * mistakes at compile time. + * + * Methods are deliberately few. Anything more sophisticated (joins, + * projections, native SQL) is a smell at this layer — model the use case as a + * domain service or a query object instead. Adding methods later (with default + * implementations) is non-breaking; removing them is not. + */ +interface Repository { + + /** + * Find by primary key. Returns `null` when no row exists for the current + * tenant — even if a row with that id exists for *another* tenant. Cross- + * tenant reads are unobservable through this API. + */ + fun findById(id: Id): T? + + /** + * Run a query and return one page of results. The page descriptor is part + * of [query], not a separate parameter, so it travels with the rest of the + * query intact (important for paginated AI-agent function calls). + */ + fun findAll(query: Query): Page + + /** + * Insert or update. Returning the saved entity (rather than `Unit`) gives + * the platform a place to populate generated columns (audit timestamps, + * generated ids) before the plug-in sees them again. + */ + fun save(entity: T): T + + /** + * Delete by id. Returns `true` if a row was deleted, `false` if no + * matching row existed for the current tenant. + */ + fun delete(id: Id): Boolean +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Transaction.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Transaction.kt new file mode 100644 index 0000000..4fdd8b1 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Transaction.kt @@ -0,0 +1,43 @@ +package org.vibeerp.api.v1.persistence + +/** + * Plug-in-facing transaction boundary. + * + * Why this exists instead of "use Spring `@Transactional`": + * - api.v1 is forbidden from exposing Spring types (CLAUDE.md guardrail #10). + * A plug-in that imports `org.springframework.transaction.annotation.Transactional` + * is grade-D and rejected at install time. + * - The host needs a single, controlled place to enforce nested-transaction + * rules, tenant binding, and outbox flushing across plug-in boundaries. + * A method-level annotation cannot do those things from a child Spring + * context cleanly. + * - It is small enough to be implemented over JTA, Spring TX, or a future + * Kotlin coroutines transactional context without an api.v1 change. + * + * Semantics: + * - The block runs in a single platform transaction. If [block] throws, the + * transaction rolls back and the exception propagates unchanged. + * - Nested calls join the surrounding transaction; they do not start a new + * one. Plug-ins that need savepoints must request them through a future + * api.v1 extension, not by stacking calls. + * - The current tenant and principal are propagated automatically. + * + * ``` + * pluginContext.transaction.inTransaction { + * val saved = repo.save(plate) + * eventBus.publish(PlateApproved(saved.id)) + * saved + * } + * ``` + */ +interface Transaction { + + /** + * Run [block] inside a transaction and return its result. + * + * @param T the type returned by the block. Use `Unit` for side-effect-only + * blocks; the host treats `Unit` no differently from any other return + * type. + */ + fun inTransaction(block: () -> T): T +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/ExtensionPoint.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/ExtensionPoint.kt new file mode 100644 index 0000000..bb45a1a --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/ExtensionPoint.kt @@ -0,0 +1,44 @@ +package org.vibeerp.api.v1.plugin + +import kotlin.reflect.KClass + +/** + * Marks an interface or abstract class as an *extension point*: a seam the + * core declares so that plug-ins (and other PBCs) can supply implementations. + * + * Why annotated rather than enforced by base-class inheritance: + * - Several extension points are plain interfaces (e.g. a custom field type + * handler, a workflow task handler, a report data provider) and forcing + * them to extend a marker class would be noise. An annotation lets the + * plug-in linter and the OpenAPI generator find them by metadata alone. + * - It documents intent at the declaration site: a reader sees + * `@ExtensionPoint` and knows "plug-ins are expected to implement this". + * - The platform's plug-in loader scans for `@Extension` (below) and + * cross-references it to `@ExtensionPoint`-annotated types so a typo + * fails at install time, not at first use. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class ExtensionPoint + +/** + * Marks a class as a plug-in's implementation of an [ExtensionPoint]. + * + * The plug-in loader instantiates every `@Extension`-annotated class in the + * plug-in's child Spring context, verifies that [point] is itself + * `@ExtensionPoint`-annotated, and registers the instance with the matching + * platform registry. + * + * ``` + * @Extension(point = TaskHandler::class) + * class ApprovePlateHandler : TaskHandler { ... } + * ``` + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class Extension( + /** The extension-point type this class implements. Must be `@ExtensionPoint`-annotated. */ + val point: KClass<*>, +) diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/Plugin.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/Plugin.kt new file mode 100644 index 0000000..17b0096 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/Plugin.kt @@ -0,0 +1,58 @@ +package org.vibeerp.api.v1.plugin + +/** + * The single entry-class contract every PF4J plug-in implements. + * + * Why this is in api.v1: + * - PF4J's own `Plugin` base class is an internal implementation choice of + * the platform-plugins module. Exposing it would tie every plug-in to a + * specific PF4J version forever — the textbook reason api.v1 exists. + * - The four methods are the bare minimum to manage a plug-in's lifecycle: + * identify itself, declare its version, start, and stop. Anything richer + * (hot reload, partial reload, health check) is added as default methods + * in later 1.x releases without breaking existing plug-ins. + * + * Lifecycle (architecture spec section 7): + * 1. The platform reads the plug-in's `plugin.yml` ([PluginManifest]). + * 2. The classloader is created. + * 3. The plug-in's entry class (which implements this interface) is + * instantiated. + * 4. [start] is called once with the [PluginContext]. The plug-in registers + * its endpoints, listeners, workflow handlers, etc. on the context. + * 5. When the plug-in is disabled or the host shuts down, [stop] is called + * once. The plug-in releases any resources it holds. The platform tears + * down the child Spring context, the listeners, and the classloader. + * + * Implementations must be safe to call [stop] without [start] having + * succeeded — the platform may invoke it after a partial start failure. + */ +interface Plugin { + + /** + * Stable plug-in identifier matching `id` in `plugin.yml`. Used as the + * key for tenant scoping, table prefixes (`plugin___*`), permission + * scoping, and the URL prefix `/api/v1/plugins/`. Must match the id + * in [PluginManifest] — the platform verifies this at start time. + */ + fun id(): String + + /** Semver version of this plug-in build. Reported in admin UIs and audit logs. */ + fun version(): String + + /** + * Called once when the plug-in is being started. The [context] gives + * access to every host service the plug-in is allowed to use. + * + * Throwing from [start] aborts the plug-in load. The platform calls + * [stop] afterwards regardless. + */ + fun start(context: PluginContext) + + /** + * Called once when the plug-in is being stopped. Implementations should + * release resources owned by the plug-in itself; listeners, endpoints, + * and Spring beans registered through [PluginContext] are torn down by + * the platform automatically. + */ + fun stop() +} 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 new file mode 100644 index 0000000..386ce9a --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt @@ -0,0 +1,82 @@ +package org.vibeerp.api.v1.plugin + +import org.vibeerp.api.v1.entity.EntityRegistry +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.persistence.Transaction +import org.vibeerp.api.v1.security.PermissionCheck + +/** + * The bag of host services a plug-in receives at [Plugin.start]. + * + * Why a single context object instead of constructor injection of each + * service: + * - Plug-ins are instantiated by PF4J before any Spring wiring exists, so + * constructor injection in the Spring sense is not yet available at + * [Plugin.start] time. Handing a context object solves the bootstrapping + * problem in one line. + * - It is forward-compatible: adding a new host service is a non-breaking + * addition to this interface (with a default implementation that throws + * `UnsupportedOperationException` — see the per-getter doc). + * + * The plug-in is free to keep references to whichever services it needs and + * pass them into its own Spring beans inside the child context. Holding onto + * the entire context is also fine. + */ +interface PluginContext { + + /** Publish/subscribe to domain events. Backed by the platform bus + outbox. */ + val eventBus: EventBus + + /** Run blocks in a platform transaction without ever importing Spring. */ + val transaction: Transaction + + /** Resolve translations using the host's ICU MessageFormat machinery. */ + val translator: Translator + + /** Read the current request/job locale. */ + val localeProvider: LocaleProvider + + /** Soft and hard permission checks. */ + val permissionCheck: PermissionCheck + + /** Look up entity descriptors (core PBC, plug-in, or user-defined). */ + val entityRegistry: EntityRegistry + + /** + * Lifecycle-bound logger. Always prefer this over `LoggerFactory.getLogger` + * because the platform tags every log line with the plug-in id, the + * tenant, the request id, and the principal automatically. A plug-in that + * brings its own SLF4J binding bypasses all of that and is hard to debug + * in a multi-plug-in deployment. + */ + val logger: PluginLogger +} + +/** + * Minimal logger interface exposed to plug-ins. + * + * Why not just expose SLF4J: + * - api.v1 forbids leaking external library types (CLAUDE.md guardrail #10). + * SLF4J version skew across plug-in classloaders is a real PF4J pain that + * a thin abstraction sidesteps entirely. + * - The host implementation forwards to whichever logging backend is + * configured at the platform layer (Logback today; structured JSON in the + * cloud build), without the plug-in choosing. + * + * Three levels are intentional. `debug` and `trace` are deliberately omitted + * from the contract because they encourage chatty plug-ins; if a plug-in + * needs verbose diagnostics it should ship its own logger inside the plug-in + * jar (which is fine — it just won't get the platform's structured tags). + */ +interface PluginLogger { + /** Informational message about normal operation. */ + fun info(message: String) + + /** Something looked wrong but the operation continued. */ + fun warn(message: String, error: Throwable? = null) + + /** An operation failed. */ + fun error(message: String, error: Throwable? = null) +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginManifest.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginManifest.kt new file mode 100644 index 0000000..d098055 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginManifest.kt @@ -0,0 +1,70 @@ +package org.vibeerp.api.v1.plugin + +/** + * Strongly-typed view of a plug-in's `plugin.yml` manifest. + * + * Why this is in api.v1 even though plug-ins do not parse it themselves: + * - The host parses `plugin.yml` and offers the result to the plug-in via + * [PluginContext] (and to admin UIs, the linter, and the marketplace). + * Putting the type in api.v1 means every consumer agrees on the schema. + * - The schema is part of the public contract: changing a field name in + * `plugin.yml` would break every existing plug-in's manifest. Documenting + * it as a Kotlin data class is the cheapest way to keep that promise. + * + * The corresponding YAML shape: + * + * ``` + * id: printingshop + * version: 1.4.2 + * requiresApi: "1.x" + * name: + * en-US: Printing Shop + * zh-CN: 印刷工坊 + * dependencies: + * - identity + * - catalog + * permissions: + * - printingshop.plate.create + * - printingshop.plate.approve + * metadata: + * - metadata/forms/plate.json + * - metadata/workflows/approval.bpmn + * ``` + * + * @property id Stable plug-in id, used everywhere ([Plugin.id], URL prefix, + * table prefix, metadata `source` tag). + * @property version Plug-in build version. Semver, but the framework does + * not enforce it parses — some customers ship date-stamped builds. + * @property requiresApi Required api.v1 major version, expressed as a range. + * The platform refuses to load a plug-in whose required range does not + * include the running host's api version. Today the only supported value + * is `"1.x"`; this field exists so the same parser handles `"2.x"` etc. + * without an api break. + * @property name Locale → display name. Locale codes are BCP-47. + * @property dependencies Other plug-in ids that must be present and started + * before this plug-in starts. The platform topologically orders startup; + * cycles are rejected at install. + * @property permissions Keys of the permissions this plug-in declares (see + * [org.vibeerp.api.v1.security.Permission]). The platform refuses to start + * a plug-in that uses a permission not in this list. + * @property metadata JAR-relative paths the platform should load at startup + * to seed forms, workflows, rules, list views, and translations. + */ +data class PluginManifest( + val id: String, + val version: String, + val requiresApi: String, + val name: Map = emptyMap(), + val dependencies: List = emptyList(), + val permissions: List = emptyList(), + val metadata: List = emptyList(), +) { + init { + require(id.isNotBlank()) { "PluginManifest id must not be blank" } + require(id.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { + "PluginManifest id may only contain letters, digits, '-', or '_' (got '$id')" + } + require(version.isNotBlank()) { "PluginManifest version must not be blank" } + require(requiresApi.isNotBlank()) { "PluginManifest requiresApi must not be blank" } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Permission.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Permission.kt new file mode 100644 index 0000000..12ccaa3 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Permission.kt @@ -0,0 +1,50 @@ +package org.vibeerp.api.v1.security + +/** + * A named permission a plug-in or PBC declares. + * + * Why permissions are first-class data, not magic strings scattered through + * code: + * - The role editor in the web UI lists every registered permission so an + * administrator can grant it. There is no way to do that if permissions + * are unenumerable. + * - The OpenAPI generator and the AI-agent function catalog annotate every + * operation with the permissions it requires; an LLM that calls the API + * can therefore refuse an action up-front rather than discovering the + * failure mid-call. + * - Plug-ins register their permissions at startup, so uninstalling a + * plug-in cleanly removes its permission rows from `metadata__permission`. + * + * Naming convention is `..`. Examples: + * - `identity.user.create` + * - `catalog.item.update` + * - `printingshop.plate.approve` (declared by the reference plug-in) + * + * The convention is enforced by [init], not by mere documentation, because + * "we'll fix the names later" never works in a permission catalog. + * + * @property key The dotted permission identifier. Lower-case, three or more + * segments separated by `.`, each segment `[a-z][a-z0-9_]*`. + * @property description Human-readable explanation shown in the role editor. + * This is a fallback English string; tenants override it through the i18n + * layer. + * @property pluginId The plug-in that owns this permission, or `null` if it + * belongs to a core PBC. The platform uses this to scope cleanup on + * plug-in uninstall. + */ +data class Permission( + val key: String, + val description: String, + val pluginId: String? = null, +) { + init { + require(description.isNotBlank()) { "Permission description must not be blank" } + require(KEY_REGEX.matches(key)) { + "Permission key must look like '..' (got '$key')" + } + } + + companion object { + private val KEY_REGEX = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){2,}$") + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/PermissionCheck.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/PermissionCheck.kt new file mode 100644 index 0000000..3dd577e --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/PermissionCheck.kt @@ -0,0 +1,54 @@ +package org.vibeerp.api.v1.security + +/** + * Plug-in-facing permission check. + * + * Why this is its own interface instead of "throw whenever you feel like it": + * - Permission checks must be enforced at the application-service layer + * (not just the HTTP layer) so background jobs, workflow tasks, AI-agent + * calls, and direct service-to-service calls all funnel through the same + * code path. A plug-in that consults this interface is automatically + * correct against every transport. + * - The two-method shape ([has] for soft checks, [require] for hard checks) + * is deliberate. Plug-ins frequently want to *render different UI* based + * on whether the user can do a thing, and that case must not throw. + * + * The platform binds this to the active [Principal] from + * [org.vibeerp.api.v1.http.RequestContext] so plug-ins almost never need to + * pass a principal explicitly — but the explicit form exists because some + * legitimate flows (impersonation, "act as", scheduled jobs queued by user A + * but executed by the system) need to ask the question for a *different* + * principal than the ambient one. + */ +interface PermissionCheck { + + /** + * Soft check: returns whether [principal] currently holds [permission]. + * Never throws on a denial. + */ + fun has(principal: Principal, permission: Permission): Boolean + + /** + * Hard check: throws [PermissionDeniedException] if [principal] does not + * hold [permission]. Returns normally otherwise. Use this at the entry + * point of any state-changing operation a plug-in exposes. + */ + fun require(principal: Principal, permission: Permission) { + if (!has(principal, permission)) { + throw PermissionDeniedException(principal, permission) + } + } +} + +/** + * Thrown by [PermissionCheck.require] when a principal lacks a permission. + * + * The two fields ([principal], [permission]) are exposed because the platform's + * HTTP error mapper turns this exception into a structured 403 response that + * includes the missing permission key — important for debuggability and for + * AI agents that need to ask the user to grant access. + */ +class PermissionDeniedException( + val principal: Principal, + val permission: Permission, +) : RuntimeException("Principal ${principal.id} lacks permission '${permission.key}'") diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt new file mode 100644 index 0000000..ef6a2f6 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt @@ -0,0 +1,85 @@ +package org.vibeerp.api.v1.security + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.core.TenantId + +/** + * Type alias for a principal identifier. + * + * Distinct phantom-typed alias of [Id] so signatures like `createdBy: PrincipalId` + * are self-documenting in IDE hovers and so the compiler refuses to mix a + * `PrincipalId` with, say, an `Id`. + */ +typealias PrincipalId = Id + +/** + * Who is making a request. + * + * vibe_erp recognizes three kinds of caller, and they all have legitimate + * audit, permission, and event-attribution needs: + * + * - [User]: a human (or an AI agent acting *on behalf of* a human, which is + * still a [User] in this model — the agent does not get its own principal + * type, which would let it circumvent per-user permissions). + * - [System]: the framework itself, e.g. background jobs, migrations, the + * metadata seeder. Recorded in audit logs as `system:` so an auditor + * can tell automation from a human. + * - [PluginPrincipal]: a plug-in acting under its own identity, used for + * plug-in-to-plug-in calls and for bookkeeping rows the plug-in writes + * without a user request (e.g. a periodic sync from an external system). + * + * Sealed because the framework needs to switch on the full set in audit and + * permission code; new principal kinds are deliberate api.v1 changes. + */ +sealed interface Principal { + + /** This principal's stable id, used as a foreign key in audit columns. */ + val id: PrincipalId + + /** + * The tenant the principal is acting in. A [User] always has one. A + * [System] principal uses [TenantId.DEFAULT] for global jobs and a + * specific tenant for per-tenant jobs. A [PluginPrincipal] uses the + * tenant of the operation it is performing. + */ + val tenantId: TenantId + + /** + * A real human user (or an AI agent acting on a human's behalf, which the + * framework intentionally does not distinguish at the principal level). + */ + data class User( + override val id: PrincipalId, + override val tenantId: TenantId, + val username: String, + ) : Principal { + init { + require(username.isNotBlank()) { "User username must not be blank" } + } + } + + /** + * The framework itself. [name] is a short identifier of which subsystem + * is acting (e.g. `metadata-seeder`, `outbox-drain`). + */ + data class System( + override val id: PrincipalId, + override val tenantId: TenantId, + val name: String, + ) : Principal { + init { + require(name.isNotBlank()) { "System name must not be blank" } + } + } + + /** A plug-in acting under its own identity. */ + data class PluginPrincipal( + override val id: PrincipalId, + override val tenantId: TenantId, + val pluginId: String, + ) : Principal { + init { + require(pluginId.isNotBlank()) { "PluginPrincipal pluginId must not be blank" } + } + } +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt new file mode 100644 index 0000000..9340dd0 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt @@ -0,0 +1,68 @@ +package org.vibeerp.api.v1.workflow + +/** + * Implementation contract for a single workflow task type. + * + * Why this is the only workflow seam in v1.0: + * - Workflows are data, not code (CLAUDE.md guardrail #2). The shape of a + * workflow lives in BPMN; the only thing a plug-in legitimately needs to + * write in code is the body of a service task. That is exactly what + * [TaskHandler] models. + * - It deliberately does not expose the Flowable `DelegateExecution` type, + * because doing so would couple every plug-in to a specific Flowable + * version forever. The platform adapts Flowable to this api.v1 shape + * inside its workflow host module. + * + * Plug-ins register handlers via the `@org.vibeerp.api.v1.plugin.Extension` + * annotation pointing at this interface, and the platform routes incoming + * Flowable service-task executions to the handler whose [key] matches. + * + * ``` + * @Extension(point = TaskHandler::class) + * class ApprovePlate : TaskHandler { + * override fun key() = "printingshop.plate.approve" + * override fun execute(task: WorkflowTask, ctx: TaskContext) { + * val plateId = task.variables["plateId"] as String + * // domain logic … + * ctx.set("approved", true) + * } + * } + * ``` + */ +interface TaskHandler { + + /** + * The BPMN service-task key this handler claims. Convention is + * `..`. Two handlers in the same running + * deployment must not return the same key — the platform fails plug-in + * load on conflict. + */ + fun key(): String + + /** + * Execute the task. The implementation may write output variables via + * [ctx]. Throwing aborts the workflow with a Flowable BPMN error, + * which the surrounding process model can catch. + */ + fun execute(task: WorkflowTask, ctx: TaskContext) +} + +/** + * Mutable side of a task execution: the only way a [TaskHandler] writes + * variables back into the workflow. + * + * Why a small interface instead of "return a Map": + * - The host wants to know which variables a handler set (for audit) and + * in what order (for diagnostic logging). A returned map loses both. + * - It leaves room to grow methods like `signal(eventName)` and + * `complete()` without changing the handler's signature. + */ +interface TaskContext { + + /** + * Set a process variable. The value is persisted by the workflow engine + * under the same transaction as the surrounding task execution. + * Passing `null` removes the variable. + */ + fun set(name: String, value: Any?) +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/WorkflowTask.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/WorkflowTask.kt new file mode 100644 index 0000000..badb63f --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/WorkflowTask.kt @@ -0,0 +1,37 @@ +package org.vibeerp.api.v1.workflow + +/** + * Description of a workflow task instance the host hands to a [TaskHandler]. + * + * Why this is in v1.0 even though the full workflow API is v0.2: + * - The reference printing-shop plug-in is the executable acceptance test + * (CLAUDE.md, "The reference plug-in is the executable acceptance test"), + * and that plug-in needs to register at least one task handler against + * the embedded Flowable engine. v1.0 ships the smallest possible + * contract that lets the plug-in do that. + * - The richer surface (signal/timer events, sub-processes, candidate + * groups, business calendars) lands in v0.2 of the api.v1 line as + * purely additive interfaces. None of it changes the shape below. + * + * @property taskKey The BPMN element id (``) that + * triggered this handler. A single handler may be wired to several keys + * inside one process; matching is performed by the host based on + * [TaskHandler.key] and the BPMN definition. + * @property processInstanceId Flowable's process-instance id, opaque to the + * plug-in. Useful for log correlation and for callbacks back into the + * workflow engine. + * @property variables The current process variables visible to this task. + * The map is read-only — to write back, the handler uses + * [TaskContext.set] which routes through Flowable so the variables are + * persisted under the right transaction. + */ +data class WorkflowTask( + val taskKey: String, + val processInstanceId: String, + val variables: Map = emptyMap(), +) { + init { + require(taskKey.isNotBlank()) { "WorkflowTask taskKey must not be blank" } + require(processInstanceId.isNotBlank()) { "WorkflowTask processInstanceId must not be blank" } + } +} diff --git a/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt b/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt new file mode 100644 index 0000000..adf51a8 --- /dev/null +++ b/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt @@ -0,0 +1,42 @@ +package org.vibeerp.api.v1.core + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.jupiter.api.Test + +class IdentifiersTest { + + @Test + fun `TenantId rejects blank`() { + assertFailure { TenantId("") } + assertFailure { TenantId(" ") } + } + + @Test + fun `TenantId rejects too-long values`() { + val tooLong = "a".repeat(65) + assertFailure { TenantId(tooLong) } + } + + @Test + fun `TenantId rejects invalid characters`() { + assertFailure { TenantId("acme corp") } // space + assertFailure { TenantId("acme.corp") } // dot + assertFailure { TenantId("acme/corp") } // slash + assertFailure { TenantId("acme!") } // punctuation + } + + @Test + fun `TenantId accepts the default tenant id`() { + assertThat(TenantId("default").value).isEqualTo("default") + assertThat(TenantId.DEFAULT.value).isEqualTo("default") + } + + @Test + fun `TenantId accepts hyphens digits and underscores`() { + assertThat(TenantId("tenant-1").value).isEqualTo("tenant-1") + assertThat(TenantId("acme_2").value).isEqualTo("acme_2") + assertThat(TenantId("ACME").value).isEqualTo("ACME") + } +} diff --git a/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/MoneyTest.kt b/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/MoneyTest.kt new file mode 100644 index 0000000..209e77e --- /dev/null +++ b/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/MoneyTest.kt @@ -0,0 +1,40 @@ +package org.vibeerp.api.v1.core + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.messageContains +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class MoneyTest { + + @Test + fun `adding two USD amounts produces a USD amount`() { + val a = Money(BigDecimal("10.00"), Currency.USD) + val b = Money(BigDecimal("2.50"), Currency.USD) + + val sum = a + b + + assertThat(sum).isEqualTo(Money(BigDecimal("12.50"), Currency.USD)) + } + + @Test + fun `adding USD to EUR throws`() { + val usd = Money(BigDecimal("10.00"), Currency.USD) + val eur = Money(BigDecimal("10.00"), Currency.EUR) + + assertFailure { usd + eur }.messageContains("different currencies") + } + + @Test + fun `JPY rejects fractional amounts because its default fraction digits is zero`() { + // JPY has 0 fraction digits — anything with a non-zero scale is wrong. + assertFailure { Money(BigDecimal("100.50"), Currency.JPY) } + .messageContains("scale") + + // A whole-yen amount is fine. + val whole = Money(BigDecimal("100"), Currency.JPY) + assertThat(whole.amount).isEqualTo(BigDecimal("100")) + } +} diff --git a/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/persistence/QueryTest.kt b/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/persistence/QueryTest.kt new file mode 100644 index 0000000..92c4ad6 --- /dev/null +++ b/api/api-v1/src/test/kotlin/org/vibeerp/api/v1/persistence/QueryTest.kt @@ -0,0 +1,76 @@ +package org.vibeerp.api.v1.persistence + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import org.junit.jupiter.api.Test + +class QueryTest { + + @Test + fun `every Filter shape can be constructed`() { + val filters: List = listOf( + Filter.Eq("name", "Acme"), + Filter.Ne("status", "DELETED"), + Filter.Lt("price", 100), + Filter.Gt("price", 1), + Filter.In("category", listOf("a", "b", "c")), + Filter.Like("name", "Ac%"), + Filter.IsNull("deleted_at"), + Filter.And(listOf(Filter.Eq("a", 1), Filter.Eq("b", 2))), + Filter.Or(listOf(Filter.Eq("a", 1), Filter.Eq("b", 2))), + Filter.Not(Filter.Eq("flag", true)), + ) + + assertThat(filters.size).isEqualTo(10) + } + + @Test + fun `Filter hierarchy can be exhaustively pattern-matched`() { + // The point of sealing Filter is that this `when` is exhaustive without + // an `else` branch. If a new variant is added without updating this + // test, the compiler fails — which is exactly the safety net we want. + fun describe(f: Filter): String = when (f) { + is Filter.Eq -> "eq" + is Filter.Ne -> "ne" + is Filter.Lt -> "lt" + is Filter.Gt -> "gt" + is Filter.In -> "in" + is Filter.Like -> "like" + is Filter.IsNull -> "isnull" + is Filter.And -> "and" + is Filter.Or -> "or" + is Filter.Not -> "not" + } + + assertThat(describe(Filter.Eq("a", 1))).isEqualTo("eq") + assertThat(describe(Filter.IsNull("x"))).isEqualTo("isnull") + assertThat(describe(Filter.Not(Filter.Eq("a", 1)))).isEqualTo("not") + } + + @Test + fun `Query default values are usable as-is`() { + val q = Query() + assertThat(q.filters).isEqualTo(emptyList()) + assertThat(q.sort).isEqualTo(emptyList()) + assertThat(q.pageRequest).isEqualTo(PageRequest.FIRST) + } + + @Test + fun `SortClause carries field and direction`() { + val s = SortClause("created_at", SortDirection.DESC) + assertThat(s.field).isEqualTo("created_at") + assertThat(s.direction).isEqualTo(SortDirection.DESC) + } + + @Test + fun `nested boolean filters compose`() { + val nested: Filter = Filter.And( + listOf( + Filter.Or(listOf(Filter.Eq("status", "OPEN"), Filter.Eq("status", "PENDING"))), + Filter.Not(Filter.IsNull("assignee")), + ) + ) + assertThat(nested).isInstanceOf(Filter.And::class) + } +}