• First runnable end-to-end demo: open the browser, log in, click
    through every PBC, and walk a sales order DRAFT → CONFIRMED →
    SHIPPED. Stock balances drop, the SALES_SHIPMENT row appears in the
    ledger, and the AR journal entry settles — all visible in the SPA
    without touching curl.
    
    Bumps version to 0.29.0-SNAPSHOT.
    
    What landed
    -----------
    
    * New `:web` Gradle subproject — Vite + React 18 + TypeScript +
      Tailwind 3.4. The Gradle wrapper is two `Exec` tasks
      (`npmInstall`, `npmBuild`) with proper inputs/outputs declared
      for incremental builds. Deliberately no node-gradle plugin —
      one less moving piece.
    
    * SPA architecture: hand-written typed REST client over `fetch`
      (auth header injection + 401 handler), AuthContext that decodes
      the JWT for display, ProtectedRoute, AppLayout with sidebar
      grouped by PBC, 16 page components covering the full v1 surface
      (Items, UoMs, Partners, Locations, Stock Balances + Movements,
      Sales Orders + detail w/ confirm/ship/cancel, Purchase Orders +
      detail w/ confirm/receive/cancel, Work Orders + detail w/
      start/complete, Shop-Floor dashboard with 5s polling, Journal
      Entries). 211 KB JS / 21 KB CSS gzipped.
    
    * Sales-order detail page: confirm/ship/cancel verbs each refresh
      the order, the (SO-filtered) movements list, and the
      (SO-filtered) journal entries — so an operator watches the
      ledger row appear and the AR row settle in real time after a
      single click. Same pattern on the purchase-order detail page
      for the AP/RECEIPT side.
    
    * Shop-floor dashboard polls /api/v1/production/work-orders/shop-
      floor every 5s and renders one card per IN_PROGRESS WO with
      current operation, planned vs actual minutes (progress bar),
      and operations-completed.
    
    * `:distribution` consumes the SPA dist via a normal Gradle
      outgoing/incoming configuration: `:web` exposes
      `webStaticBundle`, `:distribution`'s `bundleWebStatic` Sync
      task copies it into `${buildDir}/web-static/static/`, and
      that parent directory is added to the main resources source
      set so Spring Boot serves the SPA from `classpath:/static/`
      out of the same fat-jar. Single artifact, no nginx, no CORS.
    
    * New `SpaController` in platform-bootstrap forwards every
      known SPA route prefix to `/index.html` so React Router's
      HTML5 history mode works on hard refresh / deep-link entry.
      Explicit list (12 prefixes) rather than catch-all so typoed
      API URLs still get an honest 404 instead of the SPA shell.
    
    * SecurityConfiguration restructured: keeps the public allowlist
      for /api/v1/auth + /api/v1/_meta + /v3/api-docs + /swagger-ui,
      then `/api/**` is `.authenticated()`, then SPA static assets
      + every SPA route prefix are `.permitAll()`. The order is
      load-bearing — putting `.authenticated()` for /api/** BEFORE
      the SPA permitAll preserves the framework's "API is always
      authenticated" invariant even with the SPA bundled in the
      same fat-jar. The SPA bundle itself is just HTML+CSS+JS so
      permitting it is correct; secrets are gated by /api/**.
    
    * New `DemoSeedRunner` in `:distribution` (gated behind
      `vibeerp.demo.seed=true`, set in application-dev.yaml only).
      Idempotent — the runner short-circuits if its sentinel item
      (DEMO-PAPER-A4) already exists. Seeds 5 items, 2 warehouses,
      4 partners, opening stock for every item, one open
      DEMO-SO-0001 (50× business cards + 20× brochures, $720), one
      open DEMO-PO-0001 (10000× paper, $400). Every row carries
      the DEMO- prefix so it's trivially distinguishable from
      hand-created data; a future "delete demo data" command has
      an obvious filter. Production deploys never set the property,
      so the @ConditionalOnProperty bean stays absent from the
      context.
    
    How to run
    ----------
    
      docker compose up -d db
      ./gradlew :distribution:bootRun
      open http://localhost:8080
    
      # Read the bootstrap admin password from the boot log,
      # log in as admin, and walk DEMO-SO-0001 through the
      # confirm + ship flow to see the buy-sell loop in the UI.
    
    What was caught by the smoke test
    ---------------------------------
    
    * TypeScript strict mode + `error: unknown` in React state →
      `{error && <X/>}` evaluates to `unknown` and JSX rejects it.
      Fixed by typing the state as `Error | null` and converting
      in catches with `e instanceof Error ? e : new Error(String(e))`.
      Affected 16 page files; the conversion is now uniform.
    
    * DataTable's `T extends Record<string, unknown>` constraint was
      too restrictive for typed row interfaces; relaxed to
      unconstrained `T` with `(row as unknown as Record<…>)[key]`
      for the unkeyed cell read fallback.
    
    * `vite.config.ts` needs `@types/node` for `node:path` +
      `__dirname`; added to devDependencies and tsconfig.node.json
      declares `"types": ["node"]`.
    
    * KDoc nested-comment trap (4th time): SpaController's KDoc
      had `/api/v1/...` in backticks; the `/*` inside backticks
      starts a nested block comment and breaks Kotlin compilation
      with "Unclosed comment". Rephrased to "the api-v1 prefix".
    
    * SecurityConfiguration order: a draft version that put the
      SPA permit-all rules BEFORE `/api/**` authenticated() let
      unauthenticated requests reach API endpoints. Caught by an
      explicit smoke test (curl /api/v1/some-bogus-endpoint should
      return 401, not 404 from a missing static file). Reordered
      so /api/** authentication runs first.
    
    End-to-end smoke (real Postgres, fresh DB)
    ------------------------------------------
    
      - bootRun starts in 7.6s
      - DemoSeedRunner reports "populating starter dataset… done"
      - GET / returns the SPA HTML
      - GET /sales-orders, /sales-orders/<uuid>, /journal-entries
        all return 200 (SPA shell — React Router takes over)
      - GET /assets/index-*.js / /assets/index-*.css both 200
      - GET /api/v1/some-bogus-endpoint → 401 (Spring Security
        rejects before any controller mapping)
      - admin login via /api/v1/auth/login → 200 + JWT
      - GET /catalog/items → 5 DEMO-* rows
      - GET /partners/partners → 4 DEMO-* rows
      - GET /inventory/locations → 2 DEMO-* warehouses
      - GET /inventory/balances → 5 starting balances
      - POST /orders/sales-orders/<id>/confirm → CONFIRMED;
        GET /finance/journal-entries shows AR POSTED 720 USD
      - POST /orders/sales-orders/<id>/ship {"shippingLocationCode":
        "DEMO-WH-FG"} → SHIPPED; balances drop to 150 + 80;
        journal entry flips to SETTLED
      - POST /orders/purchase-orders/<id>/confirm → CONFIRMED;
        AP POSTED 400 USD appears
      - POST /orders/purchase-orders/<id>/receive → RECEIVED;
        PAPER balance grows from 5000 to 15000; AP row SETTLED
      - 8 stock_movement rows in the ledger total
    zichun authored
     
    Browse Code »

  • The reference printing-shop plug-in now actually does something:
    its main class registers two HTTP endpoints during start(context),
    and a real curl to /api/v1/plugins/printing-shop/ping returns the
    JSON the plug-in's lambda produced. End-to-end smoke test 10/10
    green. This is the chunk that turns vibe_erp from "an ERP app
    that has a plug-in folder" into "an ERP framework whose plug-ins
    can serve traffic".
    
    What landed:
    
    * api.v1 — additive (binary-compatible per the api.v1 stability rule):
      - org.vibeerp.api.v1.plugin.HttpMethod (enum)
      - org.vibeerp.api.v1.plugin.PluginRequest (path params, query, body)
      - org.vibeerp.api.v1.plugin.PluginResponse (status + body)
      - org.vibeerp.api.v1.plugin.PluginEndpointHandler (fun interface)
      - org.vibeerp.api.v1.plugin.PluginEndpointRegistrar (per-plugin
        scoped, register(method, path, handler))
      - PluginContext.endpoints getter with default impl that throws
        UnsupportedOperationException so the addition is binary-compatible
        with plug-ins compiled against earlier api.v1 builds.
    
    * platform-plugins — three new files:
      - PluginEndpointRegistry: process-wide registration storage. Uses
        Spring's AntPathMatcher so {var} extracts path variables.
        Synchronized mutation. Exact-match fast path before pattern loop.
        Rejects duplicate (method, path) per plug-in. unregisterAll(plugin)
        on shutdown.
      - ScopedPluginEndpointRegistrar: per-plugin wrapper that tags every
        register() call with the right plugin id. Plug-ins cannot register
        under another plug-in's namespace.
      - PluginEndpointDispatcher: single Spring @RestController at
        /api/v1/plugins/{pluginId}/** that catches GET/POST/PUT/PATCH/DELETE,
        asks the registry for a match, builds a PluginRequest, calls the
        handler, serializes the response. 404 on no match, 500 on handler
        throw (logged with stack trace).
      - DefaultPluginContext: implements PluginContext with a real
        SLF4J-backed logger (every line tagged with the plug-in id) and
        the scoped endpoint registrar. The other six services
        (eventBus, transaction, translator, localeProvider,
        permissionCheck, entityRegistry) throw UnsupportedOperationException
        with messages pointing at the implementation plan unit that will
        land each one. Loud failure beats silent no-op.
    
    * VibeErpPluginManager — after PF4J's startPlugins() now walks every
      loaded plug-in, casts the wrapper instance to api.v1.plugin.Plugin,
      and calls start(context) with a freshly-built DefaultPluginContext.
      Tracks the started set so destroy() can call stop() and
      unregisterAll() in reverse order. Catches plug-in start failures
      loudly without bringing the framework down.
    
    * Reference plug-in (PrintingShopPlugin):
      - Now extends BOTH org.pf4j.Plugin (so PF4J's loader can instantiate
        it via the Plugin-Class manifest entry) AND
        org.vibeerp.api.v1.plugin.Plugin (so the host's vibe_erp lifecycle
        hook can call start(context)). Uses Kotlin import aliases to
        disambiguate the two `Plugin` simple names.
      - In start(context), registers two endpoints:
          GET /ping — returns {plugin, version, ok, message}
          GET /echo/{name} — extracts path variable, echoes it back
      - The /echo handler proves path-variable extraction works end-to-end.
    
    * Build infrastructure:
      - reference-customer/plugin-printing-shop now has an `installToDev`
        Gradle task that builds the JAR and stages it into <repo>/plugins-dev/.
        The task wipes any previous staged copies first so renaming the JAR
        on a version bump doesn't leave PF4J trying to load two versions.
      - distribution's `bootRun` task now (a) depends on `installToDev`
        so the staging happens automatically and (b) sets workingDir to
        the repo root so application-dev.yaml's relative
        `vibeerp.plugins.directory: ./plugins-dev` resolves to the right
        place. Without (b) bootRun's CWD was distribution/ and PF4J found
        "No plugins" — which is exactly the bug that surfaced in the first
        smoke run.
      - .gitignore now excludes /plugins-dev/ and /files-dev/.
    
    Tests: 12 new unit tests for PluginEndpointRegistry covering literal
    paths, single/multi path variables, duplicate registration rejection,
    literal-vs-pattern precedence, cross-plug-in isolation, method
    matching, and unregisterAll. Total now 61 unit tests across the
    framework, all green.
    
    End-to-end smoke test against fresh Postgres + the plug-in JAR
    loaded by PF4J at boot (10/10 passing):
      GET /api/v1/plugins/printing-shop/ping (no auth)         → 401
      POST /api/v1/auth/login                                  → access token
      GET /api/v1/plugins/printing-shop/ping (Bearer)          → 200
                                                                 {plugin, version, ok, message}
      GET /api/v1/plugins/printing-shop/echo/hello             → 200, echoed=hello
      GET /api/v1/plugins/printing-shop/echo/world             → 200, echoed=world
      GET /api/v1/plugins/printing-shop/nonexistent            → 404 (no handler)
      GET /api/v1/plugins/missing-plugin/ping                  → 404 (no plugin)
      POST /api/v1/plugins/printing-shop/ping                  → 404 (wrong method)
      GET /api/v1/catalog/uoms (Bearer)                        → 200, 15 UoMs
      GET /api/v1/identity/users (Bearer)                      → 200, 1 user
    
    PF4J resolved the JAR, started the plug-in, the host called
    vibe_erp's start(context), the plug-in registered two endpoints, and
    the dispatcher routed real HTTP traffic to the plug-in's lambdas.
    The boot log shows the full chain.
    
    What is explicitly NOT in this chunk and remains for later:
      • plug-in linter (P1.2) — bytecode scan for forbidden imports
      • plug-in Liquibase application (P1.4) — plug-in-owned schemas
      • per-plug-in Spring child context — currently we just instantiate
        the plug-in via PF4J's classloader; there is no Spring context
        for the plug-in's own beans
      • PluginContext.eventBus / transaction / translator / etc. — they
        still throw UnsupportedOperationException with TODO messages
      • Path-template precedence between multiple competing patterns
        (only literal-beats-pattern is implemented, not most-specific-pattern)
      • Permission checks at the dispatcher (Spring Security still
        catches plug-in endpoints with the global "anyRequest authenticated"
        rule, which is the right v0.5 behavior)
      • Hot reload of plug-ins (cold restart only)
    
    Bug encountered and fixed during the smoke test:
    
      • application-dev.yaml has `vibeerp.plugins.directory: ./plugins-dev`,
        a relative path. Gradle's `bootRun` task by default uses the
        subproject's directory as the working directory, so the relative
        path resolved to <repo>/distribution/plugins-dev/ instead of
        <repo>/plugins-dev/. PF4J reported "No plugins" because that
        directory was empty. Fixed by setting bootRun.workingDir =
        rootProject.layout.projectDirectory.asFile.
    
      • One KDoc comment in PluginEndpointDispatcher contained the literal
        string `/api/v1/plugins/{pluginId}/**` inside backticks. The
        Kotlin lexer doesn't treat backticks as comment-suppressing, so
        `/**` opened a nested KDoc comment that was never closed and the
        file failed to compile. Same root cause as the AuthController bug
        earlier in the session. Rewrote the line to avoid the literal
        `/**` sequence.
    vibe_erp authored
     
    Browse Code »