Compare commits

...
Author SHA1 Message Date
james.maes 8425e6c673 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#45) from deps/align-gitea into develop
build / maven (push) Successful in 1m36s
maven / build (push) Successful in 1m36s
2026-11-07 19:50:25 +00:00
James MaesandClaude Opus 5.5 39c86cdcae refactor(help): register the help bundle with core HelpBundles
build / maven (push) Successful in 1m3s
maven / build (push) Successful in 1m3s
Replace the MarketingHelp STAND-IN (qqq#1198), which applied
help/qbit-marketing.yaml straight onto app and table metadata, with
core HelpBundles.register in the producer. Core's startup sync and
HelpTextRenderer now apply and resolve the bundle; the QBIT layer keeps
host help winning.

HelpBundleCoverageTest now runs core HelpBundleAssertions over the
qbit's own objects (QQQ's generated bulk processes stay core's), plus
widget/possible-value-source coverage and non-blank content, which the
core gate does not require. The empty-state help guardrail checks the
registered bundle as rendered by core, and a HOST-layer fixture proves
host help is not overridden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:45:24 -06:00
james.maesandClaude Opus 5.5 ec6e382b69 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Canceled after 1m10s
maven / build (push) Canceled after 1m10s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.6 -> 4.1.0-RC.26
pom.xml: qbit-assistant.version: 0.1.0-RC.12 -> 0.1.0-RC.25
pom.xml: qbit-email.version: 0.1.0-RC.3 -> 0.1.0-RC.11
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:57:07 +00:00
james.maes f4d3f2c105 Merge pull request 'ci: pin shared workflow to v1' (#44) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
build / maven (push) Successful in 5m7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 5m7s
2026-11-06 16:08:31 +00:00
james.maesandClaude Opus 5.5 5d687b37a7 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
build / maven (push) Successful in 3m7s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
maven / build (push) Successful in 3m7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:10:55 +00:00
james.maes a369e3c7b0 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#43) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
build / maven (push) Successful in 2m19s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 2m18s
2026-11-05 23:17:07 +00:00
james.maesandClaude Opus 5.5 9e465d7b7c ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m41s
build / maven (push) Successful in 2m32s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 16s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 7m14s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m33s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:06:02 +00:00
forge-recovery fe4970e10c ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 37s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 32s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
Shared Java Maven CI / verify (push) Successful in 1m53s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m41s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
qrun-release / postpublish (push) Successful in 4m39s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
Shared Java Maven CI / gate (push) Successful in 12s
qrun-release / release (push) Successful in 48m37s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 48m54s
Shared QRun source version / version (push) Successful in 4m38s
2026-11-03 18:09:54 +00:00
forge-recovery f90a1192b4 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m29s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 47s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 1h13m54s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
2026-11-02 22:34:49 +00:00
forge-recoveryandCodex 4c79c330b4 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 31s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m48s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 10m45s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:17:48 +00:00
9b01e6087c ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI / secrets (push) Failing after 18s
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m20s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 21m41s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h36m23s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:50:17 +00:00
5eabd24f9b ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
Shared Java Maven CI / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Shared Java Maven CI / secrets (push) Failing after 38s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 30s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m33s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 48m23s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 48m51s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:52 +00:00
forge-recoveryandCodex fd061f6e8c ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 2m8s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:12:59 +00:00
james.maesandCodex 4849764e2d ci: adopt managed orb 0.8 patch track (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:35:08 -05:00
james.maesandCodex 4761a0242b ci: adopt shared orb 0.7.18 (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:20 -05:00
james.maesandCodex 1bb2371d75 ci: adopt shared orb 0.7.17 (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:49 -05:00
james.maesandCodex a01ad9487f ci: centralize QBit source scanning (#32)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:41:48 -05:00
james.maesandCodex 95ae9ff23d ci: adopt central orb 0.7.12 and stable gates (#31)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:54 -05:00
james.maesandCodex eab599edac ci: centralize rc release completion in qqq orb (#30)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:24 -05:00
james.maesandCodex bd0f76cad5 ci: adopt shared orb 0.7.11 (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:21 -05:00
james.maes f8d67b28c9 Merge pull request #28 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:07:02 -05:00
james.maesandCodex e4bb261ead ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:59 -05:00
james.maes 6906859648 Merge pull request #27 from QRun-IO/feature/marketing-business-home
feat(widgets): business-only Marketing home; omit widgets without read
2026-10-16 10:19:33 -05:00
james.maesandClaude Sonnet 5.5 1c3d163104 feat(widgets): business-only Marketing home; omit widgets without read
Take Configuration Status and Agent Activity (provider configuration, model/tool
calls and tokens) off the home; expose them as MarketingWidgets.ADMIN_WIDGETS
gated on marketing.admin.read. Needs Attention gains a business-worded row per
active channel that cannot send. Home and record widgets now require
marketing.read via MarketingPermissionChecker (HIDDEN) and Base.render no longer
returns a "You need marketing.read" shell.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:12:20 -05:00
james.maes 3803e3e398 Merge pull request #26 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:21 -05:00
james.maesandClaude Opus 5.5 cb15fc0c27 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:02 -05:00
james.maes 171112162a Merge pull request #25 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 b733cba229 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:27 -05:00
james.maes c232014d28 Merge pull request #24 from QRun-IO/feature/tables-populated-defaults
feat: channel rules staleness, table help applied at run time with how rows get created
2026-10-14 17:38:28 -05:00
james.maesandClaude Opus 5.5 4bd28c5764 feat: channel rules staleness check, table help applied at run time with how rows get created
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:30:22 -05:00
james.maes 89f1c29d49 Merge pull request #23 from QRun-IO/fix/widget-timezone
fix(widgets): widget dates in the user's time zone
2026-10-14 12:37:50 -05:00
james.maesandClaude Opus 5.5 adbefe7c33 fix(widgets): show widget dates in the user's time zone
The record screens show date-times in the user's zone, so widget dates
printed in UTC disagreed with them (12:26 PM vs 5:26 PM UTC for the same
moment). Widgets now use the session's zone (else the instance's), with
the zone name: "Oct 14, 2026 12:26 PM CDT".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:33:23 -05:00
james.maes 7aa971bb22 Merge pull request #22 from QRun-IO/feature/demo-server
test: local demo server for screenshots and manual checks
2026-10-14 12:24:01 -05:00
james.maes 36a1b35ea1 Merge pull request #21 from QRun-IO/fix/widget-dates
fix(widgets): readable dates; a used approval is not "Not approved"
2026-10-14 12:23:57 -05:00
james.maesandClaude Opus 5.5 5b2589b1d5 fix(widgets): readable dates, and a used approval is not "Not approved"
Needs Attention and Proposed Publication printed raw ISO instants; they
now read like "Oct 14, 2026 4:53 PM UTC". Proposed Publication said
"Not approved" for content whose approval an execution had consumed; it
now says who approved it and that an execution used it.

Found in the demo-server screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:04:59 -05:00
james.maes f9f58d0a8f Merge pull request #20 from QRun-IO/feature/measurement-rollup
feat(measurement): daily results rollup and an outcome importer SPI (#4, part 4)
2026-10-14 12:02:51 -05:00
james.maes acc8e1a40a Merge remote-tracking branch 'origin/develop' into feature/demo-server 2026-10-14 12:01:41 -05:00
james.maesandClaude Opus 5.5 2adf563a4a test: local demo server for screenshots and manual checks
MarketingDemoServer (test sources, not shipped): H2 in memory, mock login
with every marketing permission, the verified starter data, and history
produced through the real actions (readiness, an agent draft on the
scripted provider, review, approval, schedule, a test-transport send).
Run it with the Next UI dev server; see its javadoc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:01:40 -05:00
james.maes 1ee64c95fe Merge remote-tracking branch 'origin/develop' into feature/measurement-rollup
# Conflicts:
#	README.md
2026-10-14 11:57:48 -05:00
james.maes b4387881c8 Merge pull request #19 from QRun-IO/feature/help-bundle
feat(help): help bundle (qqq#1198 format) with a coverage gate
2026-10-14 11:56:30 -05:00
james.maes 7e6667aaff Merge pull request #17 from QRun-IO/feature/crm-table-names
feat(crm): qbit-crm 0.2.0 crm_* table names
2026-10-14 11:56:19 -05:00
james.maesandClaude Opus 5.5 70d3bc65e6 docs(help): daily results table, Rebuild Results and Import Outcomes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:55:26 -05:00
james.maes 89a1a76952 Merge remote-tracking branch 'origin/feature/help-bundle' into feature/measurement-rollup
# Conflicts:
#	CHANGELOG.md
2026-10-14 11:55:25 -05:00
james.maesandClaude Opus 5.5 d5979d7308 docs(help): marketing_agent_run.heartbeatAt (from #18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:53:54 -05:00
james.maes a40d2ad5ed Merge remote-tracking branch 'origin/develop' into feature/help-bundle 2026-10-14 11:53:03 -05:00
james.maes 1c0fa34e7c Merge remote-tracking branch 'origin/develop' into feature/crm-table-names
# Conflicts:
#	CHANGELOG.md
2026-10-14 11:52:54 -05:00
james.maes b0399255df Merge pull request #18 from QRun-IO/feature/distributed-limits
feat(agent): database-counted agent concurrency and model-call rate (#4, part 2)
2026-10-14 11:51:40 -05:00
james.maes 0e37be1070 Merge remote-tracking branch 'origin/develop' into feature/measurement-rollup 2026-10-14 11:50:26 -05:00
james.maesandClaude Opus 5.5 30182b4805 feat(measurement): daily results rollup and an outcome importer SPI (#4)
marketing_metric_daily (19th table, system-written, child of the campaign)
holds per-day raw counts and unique actors per stage and event type,
recomputed after every outcome-event insert, update and delete, plus a
Rebuild Results campaign action. MeasurementReport sums the rollup and
counts campaign-wide unique actors with a backend COUNT_DISTINCT instead
of loading every event. MarketingOutcomeImporter lets hosts plug in
analytics sources, run by the Import Outcomes campaign action through
the normal (source, external id) dedupe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:50:25 -05:00
james.maesandClaude Opus 5.5 a590ae47d1 feat(help): help bundle for every marketing app, table, field, action, widget and value source
help/qbit-marketing.yaml in the qqq#1198 bundle format (layer QBIT, 346
entries), with live {{values:}}, {{label:}} and {{maxLength:}} references.
HelpBundleCoverageTest is the stand-in coverage gate until core's
HelpBundleAssertions ships; registration follows with qqq#1198.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:48:44 -05:00
james.maesandClaude Opus 5.5 1a05444e61 feat(agent): count agent concurrency and model-call rate in the database (#4)
maxConcurrentAgentRuns and maxModelCallsPerMinute were per-JVM, in memory.
Preflight now fails stale RUNNING runs (no heartbeat for maxRunSeconds
plus 60s) and counts RUNNING runs across all products; the per-minute
limit counts model-call steps in the last minute and waits instead of
failing. Runs record heartbeatAt (new column) on each model call. The
policy's maxConcurrentAgentRuns override is now honoured.

The count-then-start window remains racy; the ConditionalUpdateAction
claim from qqq#1178 (QQQ 4.1.0-RC.7) closes it in a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:46:46 -05:00
james.maesandClaude Opus 5.5 5dd68eb20b refactor(crm): crm_* table names only, no legacy fallback
qbit-crm 0.2.0 renames its tables to crm_* with no compatibility shims
(owner decision via the coordinator), so the gateway uses the snake_case
names directly and drops the camelCase fallback and LEGACY_* constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:46:24 -05:00
james.maesandClaude Opus 5.5 d732cef626 feat(crm): resolve qbit-crm's snake_case table names, legacy fallback
qbit-crm is renaming its tables to crm_* snake_case. QbitCrmGateway now
resolves each table from the instance at call time, preferring the
snake_case name and falling back to the legacy camelCase one, so either
qbit-crm generation works. Tests run the gateway against both schemes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:40:48 -05:00
james.maes 995e508e6a Merge pull request #16 from QRun-IO/feature/assistant-spi-rc12
build: assistant provider SPI RC.12 and parent 2.1.2, no allow-lists
2026-10-14 11:38:12 -05:00
james.maesandClaude Opus 5.5 0088e3b77b build: assistant provider SPI RC.12 and parent 2.1.2, no allow-lists
Move to qbit-assistant-provider-api 0.1.0-RC.12, whose types now live in
com.kingsrook.qbits.assistant.spi (ModelPricing included), and to
qbit-build-parent 2.1.2, which allows that package by default. Drop the
per-repository guardrail allow-lists. qbit-assistant-provider-openai is
only used by tests, so it becomes test scope; hosts that register it add
it themselves.

Fix the stale README status (RCs are on Central; hosted in Voyage#113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:35:23 -05:00
james.maes bd54add778 Merge pull request #14 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:57:08 -05:00
james.maes e261400b87 Merge pull request #15 from QRun-IO/feature/email-api
refactor(email): use qbit-email's public api package (fixes #13)
2026-10-14 10:34:09 -05:00
james.maesandClaude Opus 5.5 78ad854f41 refactor(email): use qbit-email's public api package (fixes #13)
QbitEmailTransport and MarketingRuntime use com.kingsrook.qbits.email.api
from qbit-email 0.1.0-RC.3; getStatus() keeps its mapping through
EmailApiReadiness.failedChecks (NOT_CONFIGURED only for the base URL and
unsubscribe key; a missing provider or brand address is still REJECTED
at send). qbit-build-parent 2.1.1 allows qbit-email's api by default, so
the temporary email allowances are gone; only the existing assistant
provider SPI allowances remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:30:56 -05:00
james.maesandClaude Opus 5.5 c0a6a00fa2 ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:07:22 -05:00
james.maes 62e12e9ebe Merge pull request #12 from QRun-IO/feature/arch-provider-api
refactor(providers): depend only on qbit-assistant-provider-api (#9)
2026-10-14 08:27:24 -05:00
james.maesandClaude Opus 5.5 e790a1091f Merge origin/develop into feature/arch-provider-api
Brings in the qbit-email transport (#11). Conflicts are resolved in
favour of the RC.6/provider-api side, keeping qbit-email.

qbit-email is a new cross-qbit implementation dependency that the
enforced G1 guardrail rejects. It is allow-listed explicitly and
temporarily until qbit-email publishes an API (#13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:18:27 -05:00
james.maes 1c5bada863 Merge pull request #11 from QRun-IO/feature/email-qbit
feat(email): QbitEmailTransport for MARKETING sends through qbit-email
2026-10-14 08:10:59 -05:00
james.maesandClaude Opus 5.5 3ec8776c2a refactor(providers): depend only on qbit-assistant-provider-api and provider-openai
Marketing compiled against qbit-assistant internals, and installing it
pulled in the whole assistant runtime. It now depends on
qbit-assistant-provider-api (the provider SPI and ModelPricing) and on the
published qbit-assistant-provider-openai, both 0.1.0-RC.11. The local
OpenAiCompatibleModelProvider is gone, and tests use a local scripted
double, so the full assistant is not needed even in test scope.

Hosts register the published provider through ProviderSettings. Its
provider name is "openai-compatible", so Marketing Policy provider
overrides must use that name.

Also move to QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.0 with the
guardrails enforced. The provider-api packages and provider-openai are
explicitly allow-listed.

Closes #9 (architecture review QRun-IO/qqq#1150, finding F5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:00:04 -05:00
james.maesandClaude Opus 5.5 b795e36c73 build: qbit-email 0.1.0-RC.1 from Maven Central
Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas
gain the RC's email_provider_sender_address table and the message
from_address_id column where they copy the qbit's DDL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:59:20 -05:00
james.maes 4bd4d0f482 Merge pull request #10 from QRun-IO/feature/contingent-possible-values
feat: contingent possible values (product-scoped references)
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 6ef32bcefe feat(email): QbitEmailTransport for MARKETING sends through qbit-email
Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an
optional dependency (0.1.0-SNAPSHOT until its first RC) used through one
adapter class behind a classpath and instance guard; without it the
existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:12:44 -05:00
55 changed files with 6480 additions and 831 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+2 -1
View File
@@ -46,7 +46,8 @@ Services: `approval.ApprovalService`, `execution.ExecutionService`, `measurement
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.1.2` (qqq 4.0.0), with architecture enforcement and strict UI API routes enabled. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Assistant provider SPI `qbit-assistant-provider-api` `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`); the OpenAI-compatible provider is test scope; no full assistant dependency. No per-repository guardrail allow-lists.
- Tests: JUnit 5, AssertJ.
## Release
+26 -2
View File
@@ -5,9 +5,28 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- Check Readiness warns when a channel's `rulesVerifiedDate` is older than the new `staleChannelRulesDays` (default 90), on a campaign whose content uses the channel, like `staleSourceDays` does for evidence sources. The warning, and the "rules not verified" one, now quote the channel's `rulesSourceUrl`. The agent's campaign view also shows the lead qualification note.
- The help bundle is registered with the QQQ core help registry (`HelpBundles.register`, qqq#1198), replacing the `MarketingHelp` stand-in, so it shows when a table is empty on hosts with core help content. `HelpBundleCoverageTest` uses core `HelpBundleAssertions`. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
- `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`.
### Changed
- The Marketing home is business content only: **Configuration Status** and **Agent Activity** (provider configuration, model/tool calls and tokens) are off the home and exposed as `MarketingWidgets.ADMIN_WIDGETS` for a host to place on its Admin area; they need `marketing.admin.read` (hidden without it). **Needs Attention** gains one row per active channel that cannot send ("Sends on <channel> are paused - ask an admin"), linking to the channel.
- Home and record widgets require `marketing.read` through `MarketingPermissionChecker` (HIDDEN): a user with app access but no read no longer sees a "You need marketing.read ..." shell; the widgets are omitted.
- `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported.
- Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up.
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
- Switch the optional qbit-email integration to the published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.3` (fixes #13). Preserve MARKETING messages, idempotency keys, execution links and outcome mapping; use typed readiness failures so missing providers or brand postal addresses remain send-time rejections. Upgrade `qbit-build-parent` to `2.1.1` and remove all per-repository guardrail allow-lists.
- Resolve architecture finding F5 (#9): depend on `qbit-assistant-provider-api` and `qbit-assistant-provider-openai` `0.1.0-RC.11`, with no full assistant dependency, including in tests.
- Replace the local OpenAI-compatible implementation with the published provider and document `ProviderSettings`, environment-key resolution and provider-name migration. Agent tests use a local scripted double.
- Upgrade QQQ to `4.1.0-RC.6`; enable architecture enforcement and strict UI API routes.
### Added
- **Persisted measurement** (issue #4, item 4): the `marketing_metric_daily` sub-table (**Daily Results** on a campaign; system-written, read-only, product-locked) rolls outcome events up per UTC day, stage, event type and test-data flag. `MeasurementRollupService` recomputes the affected campaign-days idempotently after every event insert, update and delete; **Rebuild Results** (campaign record action) rebuilds every day. `MeasurementReport` sums raw counts from the rollup and counts campaign-wide unique actors with a backend `COUNT_DISTINCT`; its output is unchanged. An event insert now defaults `occurredAt` to now and `isTestData` to false, and a blank `actorKey` is stored as null.
- **Outcome importer SPI:** `MarketingOutcomeImporter`, registered with `MarketingQBitConfig.withOutcomeImporter(...)`, and the **Import Outcomes** campaign action, which stores outcomes through the normal (source, external id) dedupe. The Configuration Status widget lists the importers. No concrete network importer.
- Help bundle `help/qbit-marketing.yaml` (346 entries) covering the app, tables, fields, actions, widgets and possible-value sources, with `HelpBundleCoverageTest` as the coverage gate until `HelpBundleAssertions` ships in core (qqq#1198). The producer registers it once that API exists.
- Product-contingent possible values for editable campaign, claim, finding, metric and outcome-event references.
- Repository scaffold: build, CI and the repository standard files.
- **Model:** 18 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
- **Model:** 19 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
- **Products and evidence:**
- Briefs, audiences, evidence sources with verification dates, and claims (Released / Planned / Unverified / Disputed).
- Deterministic **Check Readiness** for products and campaigns.
@@ -35,7 +54,7 @@ All notable changes to this project are documented here. The format follows Keep
- Narrow typed tools (6 read, 3 write-proposal), run as the user with core permission enforcement, the untrusted-data envelope and redaction.
- Run and step records with usage and cost.
- Budgets, concurrency and rate limits, cancellation, and a plain failure reason when no provider is configured.
- **Model providers:** `OpenAiCompatibleModelProvider` (no SDK), plus qbit-assistant's Claude and scripted providers.
- **Model providers:** the published `OpenAiCompatibleModelProvider` (no SDK), host-supplied providers such as Claude, and a local scripted test double.
- **AI tool kernel:** the read tools are contributed through `AiToolRegistry` when QQQ has it (QRun-IO/qqq#1096).
- **UI:**
- The Marketing app; its home is the Marketing dashboard (Needs Attention, Campaigns, Agent Activity, Configuration Status).
@@ -43,3 +62,8 @@ All notable changes to this project are documented here. The format follows Keep
- Streamed-ETL record actions with standard summary lines.
- **Starter data:** QQQ, gclo and MarkdownViewr for Windows, verified 2026-10-12, with honest starter campaigns and the four channels.
- **Docs:** README, DESIGN, OPERATIONS and STARTER-DATA.
### Changed
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
- **Email:** new `QbitEmailTransport` sends campaign email through qbit-email (one MARKETING message per recipient, unsubscribe and suppression enforced there, idempotent, linked to the execution). It is selected automatically for the `qqq_email` channel when qbit-email is produced and the host registered no such transport; `QqqMessagingEmailTransport` remains the fallback. qbit-email is an optional dependency; QQQ moves to `4.1.0-RC.5`.
+1 -1
View File
@@ -46,7 +46,7 @@ Services: `approval.ApprovalService`, `execution.ExecutionService`, `measurement
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+50 -11
View File
@@ -5,9 +5,9 @@
> Marketing for QQQ applications: products and their verified evidence, campaigns, content with revision history, exact human approval, controlled execution, honest measurement, and a marketing agent that prepares work but never publishes.
**Status:** 0.1.0, not yet published.
- Built against QQQ `4.1.0-RC.4`.
- Hosted in QRun-IO/Voyage next to qbit-crm, on a local branch.
**Status:** 0.1.0 release candidates on Maven Central (`com.kingsrook.qbits:qbit-marketing`, latest `0.1.0-RC.2`); 0.1.0 GA is not yet released.
- Built against QQQ `4.1.0-RC.6` with `qbit-build-parent` `2.1.2` and the assistant provider SPI `0.1.0-RC.12`.
- Hosted in QRun-IO/Voyage next to qbit-crm (QRun-IO/Voyage#113).
- The real integrations are implemented but unconfigured. See [what is configured](#what-is-implemented-what-is-not-configured).
It lives inside the host QQQ application: its navigation, authentication, permissions, API, record screens and widgets. It is not a separate marketing app.
@@ -22,10 +22,10 @@ It lives inside the host QQQ application: its navigation, authentication, permis
| **Approval** | A person approves a hash of the exact text, destination, recipient scope, schedule and channel. Any edit voids it. Preparation, approval and execution are separate permissions. |
| **Execution** | Durable jobs with idempotency keys, schedules, pause, resume and cancel, and attempts with provider receipts. Failures that certainly did not send are retried with backoff. Ambiguous outcomes are reconciled and never retried blindly. Everything is re-checked at send time (approval, schedule, consent, suppression, channel rules, rate, kill switch). |
| **Transports** | A complete controlled test transport, and email through the host's QQQ messaging provider. A channel that names a transport is not treated as a working connector unless that transport is registered and usable. |
| **Measurement** | Delivery, awareness, engagement, activation, repeat use and qualified demand are reported separately. Each metric shows raw events and unique actors. "Not measured" is never shown as 0. Test data is kept apart. |
| **CRM** | Uses the host CRM's own contacts, organizations, consent, suppression, lists, form submissions and activities through a narrow gateway. A qbit-crm adapter is included. Nothing is copied, and nobody becomes a lead without the campaign's explicit rule. |
| **Measurement** | Delivery, awareness, engagement, activation, repeat use and qualified demand are reported separately. Each metric shows raw events and unique actors. "Not measured" is never shown as 0. Test data is kept apart. Results are read from a persisted daily rollup (**Daily Results**, rebuilt by **Rebuild Results**), and a host can register outcome importers (**Import Outcomes**). |
| **CRM** | Uses the host CRM's own contacts, organizations, consent, suppression, lists, form submissions and activities through a narrow gateway. A qbit-crm adapter is included (qbit-crm 0.2.0's `crm_*` tables). Nothing is copied, and nobody becomes a lead without the campaign's explicit rule. |
| **Agent** | Plans campaigns, drafts and revises content, flags unsupported claims, stale sources and readiness gaps, and analyzes results to recommend the next experiment. It uses narrow typed tools, runs as the requesting user, sees data only as untrusted input, and sees no CRM personal data. Every run is recorded with its steps, provider, model, tokens and cost. It can create drafts and findings; it cannot approve, schedule or send. |
| **Models** | Interchangeable providers through qbit-assistant's `AssistantModelProvider`: Claude (`qbit-assistant-provider-anthropic`), any OpenAI-compatible endpoint (included), and a scripted provider for tests. With no provider configured, the agent says so and everything else works. |
| **Models** | Interchangeable providers through `qbit-assistant-provider-api`'s `AssistantModelProvider`: OpenAI-compatible endpoints through `qbit-assistant-provider-openai` (added by the host), or a host-supplied provider such as Claude. Tests use a local scripted double. With no provider configured, the agent says so and everything else works. Installing marketing does not install the assistant runtime, tables or routes. |
## Install
@@ -38,14 +38,24 @@ It lives inside the host QQQ application: its navigation, authentication, permis
```
```java
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleModelProvider;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings()
.withKind("OPENAI_COMPATIBLE")
.withBaseUrl("https://api.openai.com/v1")
.withApiKey(new QMetaDataVariableInterpreter().interpret("${env.MARKETING_OPENAI_API_KEY}")));
new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig()
.withBackendName("rdbms") // where the marketing_* tables live
.withCrmGateway(new QbitCrmGateway()) // optional: qbit-crm
.withSchedulerName("scheduler") // optional: runs the execution worker
.withTransport(new QqqMessagingEmailTransport() // optional: email via your QQQ messaging provider
.withMessagingProviderName("email").withFromAddress("news@example.org"))
.withModelProvider(AnthropicModelProvider.fromEnvironment()) // optional: only when ANTHROPIC_API_KEY is set
.withModel("claude-sonnet-5-5"))
.withModelProvider(provider) // optional: configure only for agent use
.withProviderName("openai-compatible")
.withModel("your-model-id"))
.produce(qInstance);
```
@@ -56,6 +66,11 @@ Then:
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) covers every setting, the run-books and the limits.
Marketing depends only on the provider SPI (`qbit-assistant-provider-api` `0.1.0-RC.12`, package `com.kingsrook.qbits.assistant.spi`). To use the OpenAI-compatible provider, the host adds `com.kingsrook.qbits:qbit-assistant-provider-openai:0.1.0-RC.12` itself.
Replace imports of marketing's former `agent.provider.OpenAiCompatibleModelProvider` with the published class above and replace its builder with `ProviderSettings`. Hosts upgrading from RC.11 change `com.kingsrook.qbits.assistant.provider.*` and `…assistant.engine.ModelPricing` imports to `com.kingsrook.qbits.assistant.spi.*`.
The host resolves the API key reference; `ProviderSettings.withApiKeyEnvName(...)` alone does not resolve it.
Set any existing Marketing Policy provider override to `openai-compatible` as well. The base URL, key environment variable name and model remain host-configurable; omit the key for a local endpoint that needs none.
## Permissions
| Permission | Grants |
@@ -83,6 +98,27 @@ Then:
- Content: Proposed Publication.
- **Actions:** every action is a streamed-ETL record action (record input, standard Review and Result summary lines) in the table's or record's Actions menu. None are nav items.
## Help
The help text ships with the code it describes, as a QQQ help bundle at `help/qbit-marketing.yaml` (a classpath resource, layer `QBIT`). It has 346 entries: the Marketing app, every `marketing_*` table and each of its non-trivial fields, every record and table action, every widget (the dashboard widgets and the child lists) and every possible-value source. Each entry has a one-line summary for the tooltip and a few sentences of plain help. Labels, maximum lengths and enum values are `{{...}}` references, so the help does not repeat what the metadata already says.
The producer registers the bundle with the QQQ core help registry (`HelpBundles.register`, qqq#1198). Registration is metadata-only; on hosts that install core help content (`HelpContentMetaDataProvider`), core's startup sync writes the entries to `helpContent` and core resolves the `{{...}}` references when it serves them, so the table help shows when a table is empty. Because the bundle is at the `QBIT` layer, a host bundle or host-edited help for the same key wins. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` runs core's `HelpBundleAssertions` gate (complete coverage, every key and reference resolves, short plain summaries) and also checks widget and possible-value-source coverage.
## Starter data and setup
A new install has no products, channels or policy row, and the agent and execution stay off until the policy row exists. Run **Load Verified Starter Data** (Marketing Product table actions, needs `marketing.prepare`) once: it adds the four starter channels, the `default` policy and three verified products with their sources, claims, audiences, draft campaigns and measurement plans. It is idempotent and never changes what exists. See [docs/STARTER-DATA.md](docs/STARTER-DATA.md). The Configuration Status widget on the home shows what is configured. The other tables (content, approvals, executions, attempts, test deliveries, agent runs and steps, findings, outcome events and daily results) hold work and results and start empty by design; their help says how rows get created. Channel rules go stale: Check Readiness warns when a channel's `rulesVerifiedDate` is older than `staleChannelRulesDays` (default 90).
## Email through qbit-email
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is produced in the same instance, email channels named `qqq_email` (the starter-data channel) send through it automatically, unless the host registered its own transport under that name. `QqqMessagingEmailTransport` stays the fallback; without qbit-email nothing changes.
- Each recipient is one `MARKETING` message. qbit-email adds the RFC 8058 unsubscribe link and headers, enforces suppression and owns retries.
- The campaign content is sent as an ad-hoc Markdown email (subject = title, body plus the call to action link); no qbit-email template is registered.
- Idempotency key `marketing:<content id>:<approval id>:<recipient key>`; the linked record is the `marketing_execution` row. A repeat is a duplicate, never a second send.
- Accepted means queued in the qbit-email outbox. A suppressed recipient is skipped, not failed (the result says how many); a refusal by qbit-email is a rejected execution with its reason.
- Needs qbit-email's `publicBaseUrl`, the `QQQ_EMAIL_UNSUBSCRIBE_KEY` secret, an enabled default provider and a default brand with a postal address. Without the first two the transport reports *Not configured* and says which is missing.
- qbit-email is an optional dependency pinned to `0.1.0-RC.3` from Maven Central (`qbit-email.version`). The integration uses only its published `com.kingsrook.qbits.email.api` package for installation checks, readiness, sends and lookups, with the parent's default guardrail allow-lists.
## What is implemented, what is not configured
| Implemented and tested | Implemented, not configured / verified live |
@@ -90,12 +126,13 @@ Then:
| All tables, actions, widgets, permissions, record locks | Claude and OpenAI-compatible providers (need credentials; tested only with the scripted provider and a local HTTP fixture) |
| Approval binding, invalidation, separation of duties | Email transport (needs the host's QQQ messaging provider; reports *Not configured* until then, and *Configured* is not *Operational*) |
| Durable execution, retries, reconciliation, leases | No social, community, blog or CMS connector exists: those channels are posted by hand |
| Controlled test transport (complete) | No analytics importer: awareness and activation metrics stay *Not measured* until something records outcome events |
| Agent loop, tools, budgets, limits, run records | QQQ AI tool kernel registration (needs QRun-IO/qqq#1096; the qbit works without it) |
| Controlled test transport (complete) | No concrete analytics importer exists (only the `MarketingOutcomeImporter` SPI): awareness and activation metrics stay *Not measured* until something records outcome events |
| Agent loop, tools, budgets, limits, run records; read-tool registration in the QQQ RC.6 AI tool registry | |
| CRM gateway and qbit-crm adapter, explicit lead rules | |
| Persisted daily rollup of outcome events, Rebuild Results, and the importer SPI (tested with a fake importer; backend `SUM` / `COUNT_DISTINCT` tested on H2) | |
| Persistence across restart (H2 file, and PostgreSQL in the Voyage host) | |
Scale gaps (single worker per JVM, in-memory rate limits) are listed in [`docs/OPERATIONS.md`](docs/OPERATIONS.md#scale-and-limits).
Scale gaps (single execution worker per JVM; agent limits are database-counted but still racy across replicas) are listed in [`docs/OPERATIONS.md`](docs/OPERATIONS.md#scale-and-limits).
## Build
@@ -105,6 +142,8 @@ Scale gaps (single worker per JVM, in-memory rate limits) are listed in [`docs/O
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM. The entities, enums and DDL are generated from one spec: `python -I scripts/generate-model.py .` (commit the output).
Architecture guardrails and strict UI API route checks are enabled with the parent's default allow-lists only (no per-repository lists): `qbit-build-parent` 2.1.2 allows the `*-api` artifacts and the `com.kingsrook.qbits.assistant.spi` and `com.kingsrook.qbits.email.api` packages. `qbit-assistant-provider-openai` is a test dependency only, and the full assistant artifact is never a dependency.
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
+9
View File
@@ -0,0 +1,9 @@
# Upgrading
## Unreleased
No schema change.
- The help bundle is now applied at run time, so tables show their help (including how rows get created) when empty. A host's own table help is kept.
- New config `staleChannelRulesDays` (default 90). Check Readiness on a campaign now also warns when a channel its content uses has a `rulesVerifiedDate` older than that. Hosts that do not want it set a large number.
- New hosts: run **Load Verified Starter Data** once to get the starter channels, the `default` policy and the starter products.
+14 -6
View File
@@ -18,8 +18,8 @@ The work is truthful and auditable: facts come from sources with dates, people a
| From | Reused for |
|---|---|
| **qbit-assistant** (0.1.0-RC.2) | The model-provider SPI: `AssistantModelProvider`, `ModelRequest` / `ModelResponse` / `ModelPart`, `StreamHandle`, `ModelProviderException` with its retryable kinds, and `ModelUsage`. Also the Claude provider (`qbit-assistant-provider-anthropic`), `ScriptedModelProvider` for tests and demos, and `ModelPricing` for cost. Marketing adds an OpenAI-compatible provider that implements the same SPI. There is no second provider abstraction. |
| **QQQ AI tool kernel** (qqq#1078, core) | The tool contract: `AbstractAiTool` / `AiToolKind` (READ, WRITE_PROPOSAL), `AiToolOutcome`, the `qqq_data` untrusted-data envelope (`AiDataEnvelope`), secret redaction (`AiRedactor`), strict input keys, and running as the user's session. The kernel's tool set is fixed in RC.4. So QRun-IO/qqq#1096 adds `AiToolRegistry`; Marketing registers its read tools there when QQQ has it and otherwise runs them in its own runner with the same contract. |
| **qbit-assistant** (0.1.0-RC.2) | The model-provider SPI: `AssistantModelProvider`, `ModelRequest` / `ModelResponse` / `ModelPart`, `StreamHandle`, `ModelProviderException` with its retryable kinds, and `ModelUsage`. Since RC.12 it lives in `com.kingsrook.qbits.assistant.spi` (`qbit-assistant-provider-api`), together with `ModelPricing` for cost; hosts register a provider such as `qbit-assistant-provider-anthropic` or `qbit-assistant-provider-openai`. There is no second provider abstraction. |
| **QQQ AI tool kernel** (qqq#1078, core) | The tool contract: `AbstractAiTool` / `AiToolKind` (READ, WRITE_PROPOSAL), `AiToolOutcome`, the `qqq_data` untrusted-data envelope (`AiDataEnvelope`), secret redaction (`AiRedactor`), strict input keys, and running as the user's session. The RC.6 baseline includes `AiToolRegistry` (QRun-IO/qqq#1096); Marketing contributes its read tools there and runs its own agent tools with the same contract. |
| **QQQ core permission enforcement** (qqq#1061) | Agent tools read with `PermissionEnforcement.ENFORCE`. Service entry points check their step's permission, then run as trusted internal work in an authorized scope, as QQQ does for calls nested in a core action. Record security locks always apply. |
| **qbit-assistant / sql-workbench metadata builders** | Explicit builder-produced tables (not annotation scanning), snake_case `marketing_*` names, QQQ standard permission suffixes, `DenyBehavior.HIDDEN`, read-only system tables, record locks, reference DDL in `docs/ddl`, `tableMetaDataCustomizer` and `inferBackendNames`. |
| **Website-Backend admin conventions** | Processes are table and record actions, never nav items, with plain labels. Record actions use `StreamedETLWithFrontendProcess` with standard summary lines. App homes are real-data dashboards built from ≤3-column full-width tables. Links use the Next route grammar (`AiLinkBuilder`). |
@@ -27,7 +27,7 @@ The work is truthful and auditable: facts come from sources with dates, people a
| **QQQ messaging** | `SendMessageAction` and the host's `QMessagingProviderMetaData` for email. |
| **QQQ ESB / Quick Search** | Every write goes through core table actions, so record-change listeners (ESB publications) and Quick Search indexing apply. Hosts list `MAIN_TABLE_NAMES` in their ESB and Quick Search config (Voyage does this for Quick Search). |
## Data model (18 tables)
## Data model (19 tables)
**Main tables** (in the nav):
@@ -61,6 +61,7 @@ The work is truthful and auditable: facts come from sources with dates, people a
| `marketing_finding` | product, campaign, content, agent run | |
| `marketing_metric` | campaign | The measurement plan. |
| `marketing_outcome_event` | campaign | Raw events, deduplicated by (source, external id). |
| `marketing_metric_daily` | campaign | **Daily Results**: the persisted rollup of the events per UTC day, stage, event type and test-data flag. System-written and read-only. |
Most rows carry a denormalized `productId`, so the optional product security lock covers every table. `scripts/generate-model.py` generates the entities, the 21 enum possible-value sources and the DDL from one spec.
@@ -75,7 +76,7 @@ Most rows carry a denormalized `productId`, so the optional product security loc
- *With disclosure* requires the disclosure text in the body.
- Optionally, the approver must not be the revision's author.
- The approval row stores the hash, revision, destination, scope and schedule.
- **Guarded tables:** approvals, executions, attempts, revisions, test deliveries, agent runs and steps are read-only in the UI and API. A guard customizer rejects any write outside Marketing's services, including the generic API and any tool.
- **Guarded tables:** approvals, executions, attempts, revisions, test deliveries, agent runs and steps, and the daily results are read-only in the UI and API. A guard customizer rejects any write outside Marketing's services, including the generic API and any tool.
## Execution
@@ -113,6 +114,13 @@ Most rows carry a denormalized `productId`, so the optional product security loc
- **Test data:** test executions and fixtures are flagged `isTestData` and excluded from the numbers, with a count.
- **Fixed limitations:** provider acceptance is not delivery; visits, stars and downloads are not adoption.
- **Deduplication:** outcome events are deduplicated by the unique key (source, external id) on every insert path: the admin UI, the API and services.
- **Daily rollup:** the report does not load the campaign's events. `marketing_metric_daily` holds, per campaign, UTC day (`dayUtc`), stage, event type and test-data flag: the raw count (sum of quantities), the unique actors of that day and the events without an actor. The unique key is (campaign, day, stage, event type, test data).
- **Kept in step:** `OutcomeEventRollupCustomizer` recomputes the affected (campaign, day)s after every insert, update and delete of an event, from any path (table, API, CRM import, importer, the execution worker). An update recomputes the old and the new day. `MeasurementRollupService` re-reads that day's raw events and upserts by the unique key, rewriting a row only when its numbers differ, and deletes rows that no longer have events. It is idempotent.
- **Rebuild Results** (campaign record action, `marketing.prepare`, the same permission as Update Results from CRM) rebuilds every day of the selected campaigns, so a damaged rollup, or events written outside Marketing, can be repaired.
- **Unique actors are not summed from days** (the same actor on two days is one actor). The report counts them over the whole campaign with a backend `COUNT_DISTINCT` of `actorKey` grouped by stage and event type; raw counts are backend `SUM`s over the rollup. Nothing is aggregated in memory.
- **Events always have a time and a test flag:** an insert defaults `occurredAt` to now and `isTestData` to false; a blank `actorKey` is stored as null; an event cannot lose its time.
- **Written only by Marketing:** the table is in `SYSTEM_WRITTEN_TABLE_NAMES` (guarded, no insert, edit or delete in the UI or API), carries `productId` for the product record lock and shows as the **Daily Results** child list on the campaign.
- **Importers:** `MarketingOutcomeImporter` (`getName()`, `fetch(campaign, since)`) is the SPI for an analytics source, registered with `MarketingQBitConfig.withOutcomeImporter(...)`. **Import Outcomes** (campaign record action) runs every importer and stores each `ImportedOutcome` through the normal insert path with the importer's name as `source`, so repeats are dropped; `since` is the newest `occurredAt` already recorded for that importer and campaign. A failing importer is named in the result and does not stop the others. The qbit ships no concrete network importer. The Configuration Status widget lists the registered importers.
## CRM integration
@@ -122,7 +130,7 @@ Most rows carry a denormalized `productId`, so the optional product security loc
- `listFormSubmissions(utmCampaign)`: ids and times only
- `recordActivity`
`QbitCrmGateway` implements it over qbit-crm 0.1.0 by table name, through the standard actions, so there is no compile dependency on the CRM:
`QbitCrmGateway` implements it over qbit-crm 0.2.0's `crm_*` tables (`crm_contact`, `crm_consent_record`, ...) by table name, through the standard actions, so there is no compile dependency on the CRM. It moves to qbit-crm's public `com.kingsrook.qbits.crm.api.CrmMarketingApi` once that is released:
- **Email consent:** the most recent Email consent record decides (Granted and not expired). No record means no consent.
- **Suppression:** `doNotEmail`, an unsubscribe date, or a list membership that isn't Subscribed.
@@ -199,7 +207,7 @@ The owner accepted every recommendation on 2026-10-12. The concrete defaults, re
| OpenAI-compatible provider | Not configured | Optional; register it only for a specific need (for example a local Ollama for experiments). With two providers the host must name one. |
| Email transport | QQQ messaging over Amazon SES, from a verified `qrun.io` domain (SPF, DKIM, DMARC), from-address `news@qrun.io`, replies to `hello@qrun.io` | SES is a low-cost, reliable sender and QQQ messaging supports it directly. Reports *Configured, not verified* until one end-to-end test send, then the host sets `withVerifiedOperational(true)`. |
| Social and community posting | No transports. Posted by hand | No connector exists, and each community's rules must be checked before any automation. |
| Measurement | Outcome events from the CRM import and by hand; no third-party analytics provider | Matches the honest-measurement rules: not instrumented shows *Not measured*. |
| Measurement | Outcome events from the CRM import, by hand and from host-registered importers (SPI only); a persisted daily rollup; no third-party analytics provider | Matches the honest-measurement rules: not instrumented shows *Not measured*. |
| CRM gateway | `QbitCrmGateway` (qbit-crm) | What Voyage's Marketing wiring uses. |
**AI-content channel policy**
+14 -6
View File
@@ -12,7 +12,7 @@
| `providerName` / `model` / `effort` | none | Which provider and model the agent uses. The Marketing Policy can override them. |
| `maxOutputTokens` | 8000 | Per model call. |
| `maxToolCallsPerRun` / `maxModelCallsPerRun` / `maxRunSeconds` | 24 / 16 / 300 | Limits of one agent run. |
| `maxConcurrentAgentRuns` / `maxModelCallsPerMinute` | 2 / 30 | Agent concurrency and rate (per JVM). |
| `maxConcurrentAgentRuns` / `maxModelCallsPerMinute` | 2 / 30 | Agent concurrency and rate, counted in the database so they hold across replicas. |
| `maxRowsToModel` | 50 | Rows per read tool. |
| `pricing` | Anthropic list prices (qbit-assistant) | USD per million tokens, by model id. Models without a price show no cost. Prices are never invented. |
| `registerKernelTools` | `true` | Contribute the read tools to the QQQ AI tool kernel (when QQQ has `AiToolRegistry`). |
@@ -23,6 +23,7 @@
| `requireDifferentApprover` | `false` | Four-eyes: the author of a revision can't approve it. |
| `aiDisclosureText` | `Prepared with AI assistance.` | Required in agent-written text for channels that allow AI content *with disclosure*. |
| `staleSourceDays` | 90 | When evidence counts as stale. |
| `staleChannelRulesDays` | 90 | When a channel's `rulesVerifiedDate` counts as stale: Check Readiness warns on a campaign whose content uses the channel, and points at the channel's `rulesSourceUrl`. |
| `crmGateway` | none | `QbitCrmGateway` for qbit-crm, or your own `MarketingCrmGateway`. |
**Fail closed:** without the policy row, or unless a switch is explicitly on, the agent and execution are **off**. **Load Verified Starter Data** creates the row, or you can create it by hand. Configuration Status shows the policy state.
@@ -48,8 +49,9 @@ Nothing in Marketing stores a credential, and every key is read from the host's
| Integration | What to provide |
|---|---|
| Claude | `ANTHROPIC_API_KEY` (or `AnthropicModelProvider.builder().apiKey(fromSecretStore)`), and a model id such as `claude-sonnet-5-5`. |
| OpenAI-compatible | A base URL (`https://api.openai.com/v1`, `http://localhost:11434/v1` for Ollama, ...), an API key reference such as `${env.MARKETING_OPENAI_API_KEY}` if the server needs one, and a model id. |
| Email | A QQQ messaging provider in the host instance (SMTP or SES), its name, and a from-address. After verifying it end to end, `withVerifiedOperational(true)`. Until then the email transport reports *Configured, not verified* and executions are blocked. |
| OpenAI-compatible | Use `com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleModelProvider` from `qbit-assistant-provider-openai` with `ProviderSettings`: a base URL (`https://api.openai.com/v1`, `http://localhost:11434/v1` for Ollama, ...), a resolved key if the server needs one, and a model id in `MarketingQBitConfig.withModel(...)`. Resolve `${env.MARKETING_OPENAI_API_KEY}` with `QMetaDataVariableInterpreter` before `withApiKey(...)`; setting `apiKeyEnvName` alone does not resolve a key. The provider name is `openai-compatible`, including in Marketing Policy overrides. See the [setup example](../README.md#install). |
| Email (qbit-email) | When qbit-email is produced in the instance and no transport named `qqq_email` is registered, email goes through its outbox (category MARKETING). It needs `publicBaseUrl`, the `QQQ_EMAIL_UNSUBSCRIBE_KEY` secret, an enabled default provider and a default brand with a postal address; a missing piece is reported as *Not configured* or as a plain rejected-execution reason. |
| Email (QQQ messaging) | A QQQ messaging provider in the host instance (SMTP or SES), its name, and a from-address. After verifying it end to end, `withVerifiedOperational(true)`. Until then the email transport reports *Configured, not verified* and executions are blocked. |
**Status:** check **Configuration Status** on the Marketing dashboard after any change.
@@ -82,10 +84,12 @@ Fix the cause. If the content changed, it needs a new approval. Then schedule ag
**Pause or withdraw one item:** use **Pause** or **Cancel** on the execution, or **Return to Draft** on the content. Return to Draft revokes the content's approvals and cancels its pending executions.
**Recording outcomes:**
- Insert outcome events through the table, the QQQ API or an importer. Set `source` and `externalEventId`: the pair is unique, so replays are dropped.
- Insert outcome events through the table, the QQQ API or an importer. Set `source` and `externalEventId`: the pair is unique, so replays are dropped. An event without `occurredAt` gets the current time.
- Set `actorKey` to an anonymous, stable id. Never an email.
- Mark test fixtures `isTestData`.
- **Update Results from CRM** imports form submissions and applies the campaign's lead rule.
- **Import Outcomes** runs every importer the host registered (`MarketingQBitConfig.withOutcomeImporter`) and stores what they report; repeats are dropped by (source, external id). Registered importers are listed in the Configuration Status widget.
- **Rebuild Results** recomputes a campaign's **Daily Results** from its raw events. Use it after events were written outside Marketing (direct SQL, a data fix) or if the rollup looks wrong. It is safe to repeat.
**Agent runs:** **Plan / Draft / Check Claims / Analyze Results with Agent** on a campaign, at most 3 campaigns per run. The run record lists every step and what the model saw. **Cancel Run** stops a run before its next model call. Runs left Running by a restart are marked Failed by the next **Run Due Executions**.
@@ -94,9 +98,13 @@ Fix the cause. If the content changed, it needs a new approval. Then schedule ag
These are real limitations of 0.1.0. Nothing here is claimed to be production-scale.
- **Single execution worker per JVM.** The worker takes an in-process lock and claims a row by re-reading its status before marking it Running. Two replicas could both claim the same due execution in the instant between read and update, and the idempotency key is then the only protection (it fully protects the test transport, but not email). **Run Marketing's worker on one replica**, or add a database-level claim (`UPDATE ... WHERE status = SCHEDULED`, or `SELECT ... FOR UPDATE SKIP LOCKED`) before scaling out.
- **Rate limits and agent concurrency are per JVM** (in memory). The channel hourly rate is computed from the database, so it holds across replicas, but only up to the race above.
- **Agent concurrency and the model-call rate are counted in the database**, so they hold across replicas. Concurrency counts `marketing_agent_run` rows in Running (all products); a run records a heartbeat on each model call, and a Running run silent for `maxRunSeconds` plus 60 seconds is marked Failed ("Stopped responding; the server may have restarted.") before the count. The rate counts model-call steps in the last 60 seconds; at the limit a run waits (bounded by `maxRunSeconds`, and Cancel Run still works) instead of failing. The channel hourly rate is computed the same way. Counting and then inserting is still racy: two replicas can both see room, so a limit can be overshot briefly. A conditional-update claim (`ConditionalUpdateAction`, qqq#1178, QQQ 4.1.0-RC.7) will close that window. Hosts add the new `heartbeat_at` column to `marketing_agent_run` (see `docs/ddl/postgres.sql`).
- **Agent runs are synchronous** inside the record action, bounded by `maxRunSeconds`. A restart interrupts them: they are marked Failed and can be run again. There is no queue or resume.
- **The worker queries due executions page by page** (`workerBatchSize` per run) and scans channel attempts for the hourly rate. That suits hundreds of executions per hour, not bulk email. Bulk outreach is deliberately not supported.
- **Measurement is computed in memory** from a campaign's events on every view. Add a rollup table before campaigns have hundreds of thousands of events.
- **Measurement is read from a persisted daily rollup** (`marketing_metric_daily`, the **Daily Results** list on a campaign), kept in step by every event insert, edit and delete, so a view reads a few rows per day plus backend aggregates (`SUM` over the rollup, `COUNT_DISTINCT` of `actorKey`), not the events. What remains:
- Each event write recomputes its (campaign, day) by reading that day's events, so a very busy day (hundreds of thousands of events) makes writes slow; batch imports are the intended way in. A bulk insert recomputes each affected day once.
- The unique-actor count is a `COUNT_DISTINCT` over all of a campaign's real events: fine for hundreds of thousands, but it scans them. Rebuild Results reads events in pages and keeps only the distinct days in memory.
- A day is the UTC date of `occurredAt`; the rollup does not follow a reporting time zone.
- Importers run synchronously inside the action, bounded by what `fetch` returns; there is no scheduler for them in 0.1.0.
- **No live verification** of providers or transports. *Configured* means the settings are present.
- **Material Dashboard** (Voyage's current frontend) may not route sub-tables that are not in the app tree. The links in the widgets follow the Next route grammar.
+23
View File
@@ -321,6 +321,7 @@ CREATE TABLE IF NOT EXISTS marketing_agent_run (
model VARCHAR(100),
requested_by_user_id VARCHAR(100),
started_at TIMESTAMP WITHOUT TIME ZONE,
heartbeat_at TIMESTAMP WITHOUT TIME ZONE,
finished_at TIMESTAMP WITHOUT TIME ZONE,
model_calls INTEGER,
tool_calls INTEGER,
@@ -440,6 +441,28 @@ CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_campaign_id ON marketing_o
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_content_id ON marketing_outcome_event (content_id);
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_execution_id ON marketing_outcome_event (execution_id);
-- A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).
CREATE TABLE IF NOT EXISTS marketing_metric_daily (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
campaign_id INTEGER NOT NULL,
product_id INTEGER,
day_utc DATE NOT NULL,
funnel_stage INTEGER NOT NULL,
event_type VARCHAR(100) NOT NULL,
is_test_data BOOLEAN NOT NULL,
raw_count INTEGER,
unique_actors INTEGER,
events_without_actor INTEGER,
recomputed_at TIMESTAMP WITHOUT TIME ZONE,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_marketing_metric_daily PRIMARY KEY (id),
CONSTRAINT uq_marketing_metric_daily_1 UNIQUE (campaign_id, day_utc, funnel_stage, event_type, is_test_data)
);
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_campaign_id ON marketing_metric_daily (campaign_id);
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_product_id ON marketing_metric_daily (product_id);
-- Runtime switches and limits: agent and execution kill switches, provider, model, budgets, concurrency.
CREATE TABLE IF NOT EXISTS marketing_policy (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+33 -8
View File
@@ -18,21 +18,23 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- The model-provider SPI (AssistantModelProvider), the scripted test -->
<!-- provider and the price table come from qbit-assistant; marketing -->
<!-- reuses them instead of a second provider abstraction. -->
<qbit-assistant.version>0.1.0-RC.2</qbit-assistant.version>
<!-- The provider SPI (com.kingsrook.qbits.assistant.spi) only: no assistant runtime. -->
<qbit-assistant.version>0.1.0-RC.25</qbit-assistant.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
<h2.version>2.3.232</h2.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -79,9 +81,24 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<artifactId>qbit-assistant-provider-api</artifactId>
<version>${qbit-assistant.version}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-openai</artifactId>
<version>${qbit-assistant.version}</version>
<!-- tests only: hosts add the provider they register (README, Install) -->
<scope>test</scope>
</dependency>
<!-- optional: email through qbit-email when it is produced in the host instance (QbitEmailTransport) -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<!-- tests: the RDBMS backend over H2 proves the DDL and persistence across a restart -->
<dependency>
@@ -89,6 +106,12 @@
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<!-- tests only: the local demo server (src/test/.../demo/MarketingDemoServer) -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
@@ -114,6 +137,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-marketing.properties</include>
</includes>
</resource>
@@ -121,6 +145,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-marketing.properties</exclude>
</excludes>
</resource>
@@ -177,14 +202,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
+6 -1
View File
@@ -171,7 +171,7 @@ TABLES = [
("MarketingAgentRun", "marketing_agent_run", "A run of the marketing agent: task, status, provider, model usage and outputs.", [
F("productId", "Fk:MarketingProduct"), F("campaignId", "Fk:MarketingCampaign"), F("contentId", "Fk:MarketingContent"),
F("task", "Enum:AgentTask", required=True), F("status", "Enum:AgentRunStatus", required=True), F("instructions", "Text"),
F("providerName", "String:64"), F("model", "String:100"), F("requestedByUserId", "String:100"), F("startedAt", "Instant"),
F("providerName", "String:64"), F("model", "String:100"), F("requestedByUserId", "String:100"), F("startedAt", "Instant"), F("heartbeatAt", "Instant"),
F("finishedAt", "Instant"), F("modelCalls", "Integer"), F("toolCalls", "Integer"), F("inputTokens", "Long"),
F("outputTokens", "Long"), F("cacheReadTokens", "Long"), F("costMicros", "Long"), F("draftsCreated", "Integer"),
F("findingsCreated", "Integer"), F("summary", "Text"), F("outputJson", "Text"), F("errorMessage", "Text")]),
@@ -194,6 +194,10 @@ TABLES = [
F("executionId", "Fk:MarketingExecution"), F("stage", "Enum:FunnelStage", required=True), F("eventType", "String:100", required=True),
F("source", "String:100", required=True), F("externalEventId", "String:200", required=True), F("actorKey", "String:200"),
F("occurredAt", "Instant"), F("quantity", "Integer"), F("isTestData", "Boolean"), F("note", "String:500")]),
("MarketingMetricDaily", "marketing_metric_daily", "A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).", [
F("campaignId", "Fk:MarketingCampaign", required=True), F("productId", "Fk:MarketingProduct"), F("dayUtc", "LocalDate", required=True),
F("funnelStage", "Enum:FunnelStage", required=True), F("eventType", "String:100", required=True), F("isTestData", "Boolean", required=True),
F("rawCount", "Integer"), F("uniqueActors", "Integer"), F("eventsWithoutActor", "Integer"), F("recomputedAt", "Instant")]),
("MarketingPolicy", "marketing_policy", "Runtime switches and limits: agent and execution kill switches, provider, model, budgets, concurrency.", [
F("name", "String:64", required=True), F("agentEnabled", "Boolean"), F("executionEnabled", "Boolean"),
F("killSwitchReason", "String:255"), F("providerName", "String:64"), F("model", "String:100"),
@@ -206,6 +210,7 @@ UNIQUES = {
"marketing_content_revision": [["contentId", "revisionNumber"]],
"marketing_execution": [["idempotencyKey"]],
"marketing_outcome_event": [["source", "externalEventId"]],
"marketing_metric_daily": [["campaignId", "dayUtc", "funnelStage", "eventType", "isTestData"]],
"marketing_test_delivery": [["idempotencyKey", "recipientKey"]],
"marketing_policy": [["name"]],
"marketing_channel": [["name"]],
@@ -21,9 +21,10 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway;
import com.kingsrook.qbits.marketing.measurement.MarketingOutcomeImporter;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -82,8 +83,14 @@ public class MarketingQBitConfig implements QBitConfig
private String aiDisclosureText = "Prepared with AI assistance.";
private Integer staleSourceDays = 90;
private Integer staleChannelRulesDays = 90;
private transient MarketingCrmGateway crmGateway;
/** Sources that are Marketing's own and cannot be an importer's name. */
private static final List<String> RESERVED_SOURCES = List.of("marketing", "crm");
private transient List<MarketingOutcomeImporter> outcomeImporters = new ArrayList<>();
/*******************************************************************************
@@ -101,6 +108,7 @@ public class MarketingQBitConfig implements QBitConfig
"agent limits (maxToolCallsPerRun, maxModelCallsPerRun, maxRunSeconds, maxConcurrentAgentRuns) must be positive", errors);
assertCondition(positive(defaultMaxAttempts) && positive(workerBatchSize) && positive(leaseSeconds) && positive(maxRecipientsPerExecution),
"execution limits (defaultMaxAttempts, workerBatchSize, leaseSeconds, maxRecipientsPerExecution) must be positive", errors);
assertCondition(positive(staleChannelRulesDays), "staleChannelRulesDays must be positive", errors);
if(StringUtils.hasContent(providerName))
{
assertCondition(modelProviders.containsKey(providerName), "providerName [" + providerName + "] is not one of the registered model providers " + modelProviders.keySet(), errors);
@@ -118,6 +126,17 @@ public class MarketingQBitConfig implements QBitConfig
names.add(transport.getName());
}
}
List<String> importerNames = new ArrayList<>();
for(MarketingOutcomeImporter importer : outcomeImporters)
{
if(assertCondition(importer != null && StringUtils.hasContent(importer.getName()), "every outcome importer needs a name", errors))
{
assertCondition(!importerNames.contains(importer.getName()), "duplicate outcome importer name [" + importer.getName() + "]", errors);
assertCondition(!RESERVED_SOURCES.contains(importer.getName()), "outcome importer name [" + importer.getName() + "] is reserved (it is the source of Marketing's own events)", errors);
importerNames.add(importer.getName());
}
}
}
@@ -168,6 +187,27 @@ public class MarketingQBitConfig implements QBitConfig
/*******************************************************************************
** Register an outcome importer (run by the Import Outcomes campaign action).
*******************************************************************************/
public MarketingQBitConfig withOutcomeImporter(MarketingOutcomeImporter importer)
{
this.outcomeImporters.add(importer);
return (this);
}
/*******************************************************************************
** Getter for outcomeImporters
*******************************************************************************/
public List<MarketingOutcomeImporter> getOutcomeImporters()
{
return (this.outcomeImporters);
}
/*******************************************************************************
** Getter for backendName
*******************************************************************************/
@@ -752,6 +792,28 @@ public class MarketingQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for staleChannelRulesDays: how long a channel's rulesVerifiedDate
** stays current before Check Readiness asks for the rules to be re-read.
*******************************************************************************/
public Integer getStaleChannelRulesDays()
{
return (this.staleChannelRulesDays);
}
/*******************************************************************************
** Fluent setter for staleChannelRulesDays
*******************************************************************************/
public MarketingQBitConfig withStaleChannelRulesDays(Integer staleChannelRulesDays)
{
this.staleChannelRulesDays = staleChannelRulesDays;
return (this);
}
/*******************************************************************************
** Getter for staleSourceDays
*******************************************************************************/
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
@@ -46,7 +47,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Produces the Marketing QBit into a host application:
**
** - 18 standard tables (marketing_*), their joins, child-list widgets and
** - 19 standard tables (marketing_*), their joins, child-list widgets and
** possible-value sources, and the optional product security key type;
** - the record actions (streamed ETL) and two table actions;
** - the Marketing app, whose home is the Marketing dashboard;
@@ -70,6 +71,7 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-marketing";
public static final String VERSION = loadVersion();
public static final String HELP_BUNDLE = "help/qbit-marketing.yaml";
private MarketingQBitConfig qBitConfig;
private String namespace;
@@ -200,6 +202,11 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
}
qInstance.addApp(builder.buildApp(MarketingWidgets.HOME_WIDGETS));
///////////////////////////////////////////////////////////////////////////
// help: register the bundle with core; registration is metadata-only and //
// core's startup sync applies it on hosts that have the helpContent table //
///////////////////////////////////////////////////////////////////////////
HelpBundles.register(qInstance, HELP_BUNDLE, MarketingQBitProducer.class.getClassLoader());
////////////////////////////////////
// runtime and kernel tools //
@@ -18,20 +18,20 @@ package com.kingsrook.qbits.marketing;
import java.net.InetAddress;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway;
import com.kingsrook.qbits.marketing.email.QbitEmailTransport;
import com.kingsrook.qbits.marketing.measurement.MarketingOutcomeImporter;
import com.kingsrook.qbits.marketing.model.MarketingPolicy;
import com.kingsrook.qbits.marketing.transport.ControlledTestTransport;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -48,14 +48,15 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The live parts of the Marketing QBit for one instance: the transports, the
** model providers, the CRM gateway, and the in-process limits (agent
** concurrency, model-call rate, the single execution worker).
** model providers, the CRM gateway, and the single execution worker's
** in-process lock. (Agent concurrency and the model-call rate are counted in
** the database by MarketingAgentService.)
**
** Registered as supplemental instance meta-data (not published to
** frontends), so process steps and widgets find it from QContext.
**
** Scale note: the limits here are per JVM. One replica is the supported shape
** today; see docs/OPERATIONS.md ("Scale and limits").
** Scale note: the execution worker is per JVM. One worker replica is the
** supported shape today; see docs/OPERATIONS.md ("Scale and limits").
*******************************************************************************/
public class MarketingRuntime implements QSupplementalInstanceMetaData
{
@@ -67,10 +68,9 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
private final MarketingQBitConfig config;
private final Map<String, MarketingTransport> transports = new LinkedHashMap<>();
private final Semaphore agentPermits;
private final ReentrantLock workerLock = new ReentrantLock();
private final Deque<Long> modelCallTimes = new ArrayDeque<>();
private final String workerName;
private volatile MarketingTransport qbitEmailTransport;
@@ -88,7 +88,6 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
{
transports.put(transport.getName(), transport);
}
this.agentPermits = new Semaphore(config.getMaxConcurrentAgentRuns());
this.workerName = hostName() + ":" + UUID.randomUUID().toString().substring(0, 8);
}
@@ -131,11 +130,47 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
/*******************************************************************************
** A transport by name, or null.
** A transport by name, or null. When the host registered no transport named
** qqq_email (the default email channel name) and qbit-email is produced in the
** instance, that name resolves to QbitEmailTransport; a host-registered
** transport always wins, and QqqMessagingEmailTransport remains the fallback
** the host can register.
*******************************************************************************/
public MarketingTransport getTransport(String name)
{
return (name == null ? null : transports.get(name));
if(name == null)
{
return (null);
}
MarketingTransport transport = transports.get(name);
if(transport == null && QqqMessagingEmailTransport.DEFAULT_NAME.equals(name) && emailQBitClassPresent() && QbitEmailTransport.isInstalled(QContext.getQInstance()))
{
if(qbitEmailTransport == null)
{
qbitEmailTransport = new QbitEmailTransport();
}
return (qbitEmailTransport);
}
return (transport);
}
/*******************************************************************************
** qbit-email is an optional dependency: touch QbitEmailTransport only when
** its classes are present.
*******************************************************************************/
private static boolean emailQBitClassPresent()
{
try
{
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
@@ -284,52 +319,22 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
list.add(new IntegrationStatus("CRM", gateway.getName(), gateway.isAvailable() ? "Available" : "Not available",
gateway.isAvailable() ? "Uses the CRM's own contact, consent, list and activity tables." : "The CRM's tables are not in this application."));
}
if(config.getOutcomeImporters().isEmpty())
{
list.add(new IntegrationStatus("Outcome importer", "(none)", "Not configured",
"No importer: outcomes are recorded by hand, through the API or by the CRM import, so other metrics stay Not measured."));
}
for(MarketingOutcomeImporter importer : config.getOutcomeImporters())
{
list.add(new IntegrationStatus("Outcome importer", importer.getName(), "Registered (not verified by a live call)",
"Runs from Import Outcomes on a campaign; events are deduplicated by source and external id."));
}
return (list);
}
/*******************************************************************************
** Try to start an agent run (bounded concurrency). Release with
** releaseAgentPermit.
*******************************************************************************/
public boolean tryAcquireAgentPermit()
{
return (agentPermits.tryAcquire());
}
/*******************************************************************************
**
*******************************************************************************/
public void releaseAgentPermit()
{
agentPermits.release();
}
/*******************************************************************************
** Reserve a model call within the per-minute rate (false = over the limit).
*******************************************************************************/
public synchronized boolean tryReserveModelCall()
{
long now = System.currentTimeMillis();
while(!modelCallTimes.isEmpty() && modelCallTimes.peekFirst() < now - 60_000)
{
modelCallTimes.pollFirst();
}
if(modelCallTimes.size() >= config.getMaxModelCallsPerMinute())
{
return (false);
}
modelCallTimes.addLast(now);
return (true);
}
/*******************************************************************************
** The single execution worker's lock in this JVM.
*******************************************************************************/
@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
import com.kingsrook.qbits.marketing.security.MarketingInternalWrite;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@@ -33,6 +34,8 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
@@ -171,6 +174,38 @@ public final class MarketingStore
/*******************************************************************************
** Delete records by primary key. Errors become exceptions.
*******************************************************************************/
public static void delete(String tableName, List<Serializable> ids) throws QException
{
if(ids == null || ids.isEmpty())
{
return;
}
DeleteOutput output = new DeleteAction().execute(new DeleteInput(tableName).withPrimaryKeys(ids));
if(CollectionUtils.nullSafeHasContents(output.getRecordsWithErrors()))
{
throw (new QUserFacingException(output.getRecordsWithErrors().get(0).getErrors().stream().map(e -> e.getMessage()).collect(Collectors.joining("; "))));
}
}
/*******************************************************************************
** Delete from a system-written table.
*******************************************************************************/
public static void deleteInternal(String tableName, List<Serializable> ids) throws QException
{
MarketingInternalWrite.run(() ->
{
delete(tableName, ids);
return (null);
});
}
/*******************************************************************************
** Insert into a system-written table.
*******************************************************************************/
@@ -25,18 +25,18 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.MarketingStore;
@@ -78,11 +78,15 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** A run:
** 1. checks the policy (kill switch), the provider and model, the daily
** token and monthly cost budgets, and the concurrency limit - failing the
** run with a plain reason when any is missing (missing-provider behavior);
** run with a plain reason when any is missing (missing-provider behavior).
** The concurrency limit counts RUNNING runs in the database (after failing
** those whose heartbeat stopped), so it holds across replicas;
** 2. runs the deterministic readiness checks (a CHECK step);
** 3. loops model call -> tool calls, bounded by model calls, tool calls,
** wall-clock time and the per-minute model-call rate; it re-reads its own
** status before every model call so Cancel Run stops it;
** wall-clock time and the per-minute model-call rate (counted from the
** database; at the limit the run waits, it does not fail); it re-reads its
** own status before every model call so Cancel Run stops it, and records a
** heartbeat on each model call;
** 4. records every model call (tokens, stop reason) and tool call (input,
** outcome, enveloped result), the usage and cost, the drafts and findings
** it created, and its final summary.
@@ -98,8 +102,29 @@ public class MarketingAgentService
static final int MAX_STEP_JSON = 20_000;
/////////////////////////////////////////////////////////////////////////////
// a RUNNING run silent for maxRunSeconds plus this long is not alive //
/////////////////////////////////////////////////////////////////////////////
public static final int STALE_GRACE_SECONDS = 60;
private final AiRedactor redactor = new AiRedactor(List.of());
private Duration modelCallWindow = Duration.ofSeconds(60);
private Duration modelCallPoll = Duration.ofSeconds(1);
/*******************************************************************************
** Change the per-minute window and how often a waiting run re-checks it
** (the defaults are 60 seconds and 1 second; tests use short ones).
*******************************************************************************/
public MarketingAgentService withModelCallWindow(Duration window, Duration poll)
{
this.modelCallWindow = window;
this.modelCallPoll = poll;
return (this);
}
/*******************************************************************************
@@ -158,19 +183,7 @@ public class MarketingAgentService
return (finish(run, AgentRunStatus.FAILED, null, refusal, null, new MarketingToolContext(run, 1), new ModelUsage(), 0, 0));
}
if(!runtime.tryAcquireAgentPermit())
{
return (finish(run, AgentRunStatus.FAILED, null, "Too many agent runs at once (limit " + runtime.getConfig().getMaxConcurrentAgentRuns() + "); start it again shortly.",
null, new MarketingToolContext(run, 1), new ModelUsage(), 0, 0));
}
try
{
return (execute(runtime, policy, provider, model, run, task));
}
finally
{
runtime.releaseAgentPermit();
}
return (execute(runtime, policy, provider, model, run, task));
}
@@ -213,6 +226,101 @@ public class MarketingAgentService
return ("This month's agent cost budget is used up.");
}
}
/////////////////////////////////////////////////////////////////////////////
// concurrency is counted in the database, across every replica and //
// product. NOTE: count-then-mark-RUNNING is still racy - two replicas can //
// both see room and both start. A conditional-update claim //
// (ConditionalUpdateAction, qqq#1178, QQQ 4.1.0-RC.7) will close that //
// window as a follow-up. //
/////////////////////////////////////////////////////////////////////////////
failStaleRuns(runtime.getConfig());
int limit = policy.getMaxConcurrentAgentRuns() != null ? policy.getMaxConcurrentAgentRuns() : runtime.getConfig().getMaxConcurrentAgentRuns();
if(runningRuns() >= limit)
{
return ("Too many agent runs at once (limit " + limit + "); start it again shortly.");
}
return (null);
}
/*******************************************************************************
** RUNNING runs (all products) whose heartbeat - or start, for a run that
** never beat - is older than maxRunSeconds plus a grace period are failed:
** their server stopped. Returns how many.
*******************************************************************************/
int failStaleRuns(MarketingQBitConfig config) throws QException
{
Instant cutoff = Instant.now().minusSeconds((long) config.getMaxRunSeconds() + STALE_GRACE_SECONDS);
List<QRecord> running = MarketingStore.asSystem(() -> MarketingStore.query(MarketingAgentRun.TABLE_NAME,
new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.RUNNING.getId()))));
int count = 0;
for(QRecord record : running)
{
Instant last = record.getValueInstant("heartbeatAt");
last = last != null ? last : record.getValueInstant("startedAt");
last = last != null ? last : record.getValueInstant("createDate");
if(last != null && last.isBefore(cutoff))
{
MarketingStore.updateInternal(MarketingAgentRun.TABLE_NAME, new QRecord().withValue("id", record.getValue("id"))
.withValue("status", AgentRunStatus.FAILED.getId()).withValue("finishedAt", Instant.now())
.withValue("errorMessage", "Stopped responding; the server may have restarted."));
count++;
}
}
return (count);
}
/*******************************************************************************
** RUNNING runs across all products, counted as the system user.
*******************************************************************************/
static int runningRuns() throws QException
{
return (MarketingStore.asSystem(() -> MarketingStore.count(MarketingAgentRun.TABLE_NAME,
new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.RUNNING.getId())))));
}
/*******************************************************************************
** Model calls (steps of the model-call kind, all runs, system user) recorded
** since the instant.
*******************************************************************************/
static int modelCallsSince(Instant since) throws QException
{
return (MarketingStore.asSystem(() -> MarketingStore.count(MarketingAgentStep.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("kind", QCriteriaOperator.EQUALS, AgentStepKind.MODEL_CALL.getId()),
new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since)))));
}
/*******************************************************************************
** Wait until the per-minute model-call limit has room. Returns null to go
** ahead, CANCELLED if a person cancelled the run while waiting, or FAILED
** if the run's time ran out first.
**
** NOTE: counting then calling is racy across replicas (see preflight); the
** conditional-update claim follow-up (qqq#1178) will close it.
*******************************************************************************/
private AgentRunStatus awaitModelCallSlot(MarketingAgentRun run, int limit, Instant deadline) throws QException, InterruptedException
{
while(modelCallsSince(Instant.now().minus(modelCallWindow)) >= limit)
{
if(isCancelled(run.getId()))
{
return (AgentRunStatus.CANCELLED);
}
if(Instant.now().isAfter(deadline))
{
return (AgentRunStatus.FAILED);
}
Thread.sleep(modelCallPoll.toMillis());
}
return (null);
}
@@ -229,7 +337,7 @@ public class MarketingAgentService
int maxTools = policy != null && policy.getMaxToolCallsPerRun() != null ? policy.getMaxToolCallsPerRun() : config.getMaxToolCallsPerRun();
MarketingStore.updateInternal(MarketingAgentRun.TABLE_NAME, new QRecord().withValue("id", run.getId())
.withValue("status", AgentRunStatus.RUNNING.getId()).withValue("startedAt", Instant.now()));
.withValue("status", AgentRunStatus.RUNNING.getId()).withValue("startedAt", Instant.now()).withValue("heartbeatAt", Instant.now()));
MarketingToolContext context = new MarketingToolContext(run, config.getMaxRowsToModel());
ModelUsage usage = new ModelUsage();
@@ -278,11 +386,21 @@ public class MarketingAgentService
{
return (finish(run, AgentRunStatus.FAILED, null, "Stopped: the run reached its limit of " + config.getMaxModelCallsPerRun() + " model calls.", null, context, usage, modelCalls, toolCalls));
}
if(!runtime.tryReserveModelCall())
/////////////////////////////////////////////////////////////////
// at the per-minute limit, wait (bounded by the run's time) //
/////////////////////////////////////////////////////////////////
AgentRunStatus blocked = awaitModelCallSlot(run, config.getMaxModelCallsPerMinute(), deadline);
if(blocked == AgentRunStatus.CANCELLED)
{
return (finish(run, AgentRunStatus.FAILED, null, "Stopped: the model-call rate limit (" + config.getMaxModelCallsPerMinute() + " per minute) was reached.", null, context, usage, modelCalls,
toolCalls));
return (finish(run, AgentRunStatus.CANCELLED, null, "Cancelled by a person.", null, context, usage, modelCalls, toolCalls));
}
if(blocked == AgentRunStatus.FAILED)
{
return (finish(run, AgentRunStatus.FAILED, null, "Stopped: the run exceeded " + config.getMaxRunSeconds() + " seconds, waiting for the model-call rate limit ("
+ config.getMaxModelCallsPerMinute() + " per minute).", null, context, usage, modelCalls, toolCalls));
}
MarketingStore.updateInternal(MarketingAgentRun.TABLE_NAME, new QRecord().withValue("id", run.getId()).withValue("heartbeatAt", Instant.now()));
ModelRequest request = new ModelRequest()
.withModel(model)
@@ -1,512 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.agent.provider;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** A model provider for any OpenAI-compatible Chat Completions endpoint
** (POST {baseUrl}/chat/completions): OpenAI, and the many servers that speak
** the same API (OpenRouter, Ollama, vLLM, LM Studio, LiteLLM, ...).
** Implements qbit-assistant's AssistantModelProvider, so it plugs into the
** marketing agent (and the assistant) next to the Anthropic provider.
**
** - No SDK: java.net.http. Non-streaming request; the text is delivered to
** the listener at the end.
** - The API key is read from the environment / secret reference given to
** the builder ("${env.NAME}"); it is never logged, and toString never
** contains it. A provider without a key is allowed for local servers that
** need none (Ollama).
** - Errors map to ModelProviderException kinds: 401/403 AUTHENTICATION,
** 429 RATE_LIMITED, 5xx SERVER / OVERLOADED (503), 400/404/422
** INVALID_REQUEST, timeouts TIMEOUT, I/O NETWORK. Messages carry the HTTP
** status and the API's error type only.
*******************************************************************************/
public class OpenAiCompatibleModelProvider implements AssistantModelProvider
{
private final String name;
private final String baseUrl;
private final String apiKey;
private final Duration requestTimeout;
private final HttpClient httpClient;
private final Boolean strictTools;
/*******************************************************************************
** Use the builder.
*******************************************************************************/
private OpenAiCompatibleModelProvider(Builder builder)
{
this.name = builder.name;
this.baseUrl = builder.baseUrl.endsWith("/") ? builder.baseUrl.substring(0, builder.baseUrl.length() - 1) : builder.baseUrl;
String key = builder.apiKey;
if(key != null && key.startsWith("${"))
{
key = new QMetaDataVariableInterpreter().interpret(key);
}
this.apiKey = StringUtils.hasContent(key) ? key : null;
this.requestTimeout = builder.requestTimeout;
this.strictTools = builder.strictTools;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
}
/*******************************************************************************
** A builder.
*******************************************************************************/
public static Builder builder()
{
return (new Builder());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ModelCapabilities getCapabilities(String modelId)
{
return (new ModelCapabilities().withTools(true).withParallelToolCalls(true).withPromptCaching(false).withThinking(false).withMaxContextTokens(128_000));
}
/*******************************************************************************
** Does it have a key? (Local servers may need none.)
*******************************************************************************/
public boolean hasApiKey()
{
return (apiKey != null);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
CompletableStreamHandle handle = new CompletableStreamHandle();
CompletableFuture<HttpResponse<String>> future;
try
{
HttpRequest.Builder http = HttpRequest.newBuilder(URI.create(baseUrl + "/chat/completions"))
.timeout(requestTimeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JsonUtils.toJson(toRequestBody(request))));
if(apiKey != null)
{
http.header("Authorization", "Bearer " + apiKey);
}
future = httpClient.sendAsync(http.build(), HttpResponse.BodyHandlers.ofString());
}
catch(Exception e)
{
handle.fail(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, null, "Could not build the request: " + e.getClass().getSimpleName()));
return (handle);
}
handle.setOnCancel(() -> future.cancel(true));
future.whenComplete((response, error) ->
{
if(error != null)
{
Throwable cause = error.getCause() != null ? error.getCause() : error;
if(cause instanceof HttpTimeoutException)
{
handle.fail(new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "The request timed out."));
}
else if(cause instanceof IOException)
{
handle.fail(new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "Network error: " + cause.getClass().getSimpleName()));
}
else
{
handle.fail(new ModelProviderException(ModelProviderException.Kind.OTHER, null, "Request failed: " + cause.getClass().getSimpleName()));
}
return;
}
try
{
if(response.statusCode() >= 300)
{
handle.fail(toException(response.statusCode(), response.body()));
return;
}
ModelResponse modelResponse = fromResponseBody(response.body());
String text = modelResponse.getText();
if(StringUtils.hasContent(text))
{
listener.onTextDelta(text);
}
handle.complete(modelResponse);
}
catch(Exception e)
{
handle.fail(new ModelProviderException(ModelProviderException.Kind.OTHER, response.statusCode(), "Unreadable response: " + e.getClass().getSimpleName()));
}
});
return (handle);
}
/*******************************************************************************
** ModelRequest -> Chat Completions request body.
*******************************************************************************/
Map<String, Object> toRequestBody(ModelRequest request)
{
List<Map<String, Object>> messages = new ArrayList<>();
StringBuilder system = new StringBuilder();
for(SystemBlock block : request.getSystem())
{
if(system.length() > 0)
{
system.append("\n\n");
}
system.append(block.getText());
}
if(system.length() > 0)
{
messages.add(Map.of("role", "system", "content", system.toString()));
}
for(ModelMessage message : request.getMessages())
{
if(message.getRole() == ModelMessage.Role.ASSISTANT)
{
Map<String, Object> assistant = new LinkedHashMap<>();
StringBuilder text = new StringBuilder();
List<Map<String, Object>> toolCalls = new ArrayList<>();
for(ModelPart part : message.getParts())
{
if(part.getType() == ModelPart.Type.TEXT && part.getText() != null)
{
text.append(part.getText());
}
else if(part.getType() == ModelPart.Type.TOOL_USE)
{
toolCalls.add(Map.of("id", part.getToolUseId(), "type", "function",
"function", Map.of("name", part.getToolName(), "arguments", JsonUtils.toJson(part.getInput() == null ? Map.of() : part.getInput()))));
}
}
assistant.put("role", "assistant");
assistant.put("content", text.length() == 0 ? null : text.toString());
if(!toolCalls.isEmpty())
{
assistant.put("tool_calls", toolCalls);
}
messages.add(assistant);
}
else
{
StringBuilder text = new StringBuilder();
for(ModelPart part : message.getParts())
{
if(part.getType() == ModelPart.Type.TOOL_RESULT)
{
messages.add(Map.of("role", "tool", "tool_call_id", part.getToolUseId(), "content", part.getText() == null ? "" : part.getText()));
}
else if(part.getText() != null)
{
text.append(part.getText());
}
}
if(text.length() > 0)
{
messages.add(Map.of("role", "user", "content", text.toString()));
}
}
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", request.getModel());
body.put("messages", messages);
if(request.getMaxTokens() != null)
{
body.put("max_completion_tokens", request.getMaxTokens());
}
if(!request.getTools().isEmpty())
{
List<Map<String, Object>> tools = new ArrayList<>();
for(ModelToolDefinition tool : request.getTools())
{
Map<String, Object> function = new LinkedHashMap<>();
function.put("name", tool.getName());
function.put("description", tool.getDescription());
function.put("parameters", tool.getInputSchema());
////////////////////////////////////////////////////////////////////
// OpenAI's strict mode needs every property required; the //
// agent validates inputs itself, so strict is off by default. //
////////////////////////////////////////////////////////////////////
if(Boolean.TRUE.equals(strictTools))
{
function.put("strict", true);
}
tools.add(Map.of("type", "function", "function", function));
}
body.put("tools", tools);
body.put("parallel_tool_calls", Boolean.TRUE.equals(request.getParallelToolCalls()));
}
return (body);
}
/*******************************************************************************
** Chat Completions response body -> ModelResponse.
*******************************************************************************/
@SuppressWarnings("unchecked")
ModelResponse fromResponseBody(String json) throws Exception
{
Map<String, Object> root = JsonUtils.toObject(json, new TypeReference<>() {});
List<Map<String, Object>> choices = (List<Map<String, Object>>) root.get("choices");
if(choices == null || choices.isEmpty())
{
throw (new IllegalStateException("no choices"));
}
Map<String, Object> choice = choices.get(0);
Map<String, Object> message = (Map<String, Object>) choice.get("message");
ModelResponse response = new ModelResponse();
Object content = message == null ? null : message.get("content");
if(content instanceof String text && !text.isEmpty())
{
response.getParts().add(ModelPart.text(text));
}
List<Map<String, Object>> toolCalls = message == null ? null : (List<Map<String, Object>>) message.get("tool_calls");
if(toolCalls != null)
{
for(Map<String, Object> call : toolCalls)
{
Map<String, Object> function = (Map<String, Object>) call.get("function");
String arguments = function == null ? null : (String) function.get("arguments");
Map<String, Object> input = StringUtils.hasContent(arguments) ? JsonUtils.toObject(arguments, new TypeReference<Map<String, Object>>() {}) : new LinkedHashMap<>();
response.getParts().add(ModelPart.toolUse((String) call.get("id"), function == null ? null : (String) function.get("name"), input));
}
}
String finish = (String) choice.get("finish_reason");
response.setStopReason(switch(finish == null ? "" : finish)
{
case "tool_calls", "function_call" -> ModelResponse.STOP_TOOL_USE;
case "length" -> ModelResponse.STOP_MAX_TOKENS;
case "content_filter" -> ModelResponse.STOP_REFUSAL;
default -> response.getToolUses().isEmpty() ? ModelResponse.STOP_END_TURN : ModelResponse.STOP_TOOL_USE;
});
Map<String, Object> usage = (Map<String, Object>) root.get("usage");
if(usage != null)
{
long prompt = number(usage.get("prompt_tokens"));
long cached = 0;
if(usage.get("prompt_tokens_details") instanceof Map<?, ?> details)
{
cached = number(details.get("cached_tokens"));
}
response.setUsage(new ModelUsage().withInputTokens(Math.max(0, prompt - cached)).withCacheReadTokens(cached).withOutputTokens(number(usage.get("completion_tokens"))));
}
response.setRequestId((String) root.get("id"));
return (response);
}
/*******************************************************************************
** An HTTP error -> a provider exception (status and error type only).
*******************************************************************************/
@SuppressWarnings("unchecked")
static ModelProviderException toException(int status, String body)
{
String type = null;
try
{
Map<String, Object> root = JsonUtils.toObject(body, new TypeReference<>() {});
Object error = root.get("error");
if(error instanceof Map<?, ?> map)
{
type = map.get("type") instanceof String s ? s : (map.get("code") instanceof String c ? c : null);
}
}
catch(Exception e)
{
// not JSON: the status alone
}
ModelProviderException.Kind kind = switch(status)
{
case 401, 403 -> ModelProviderException.Kind.AUTHENTICATION;
case 429 -> ModelProviderException.Kind.RATE_LIMITED;
case 503, 529 -> ModelProviderException.Kind.OVERLOADED;
case 400, 404, 413, 422 -> ModelProviderException.Kind.INVALID_REQUEST;
case 408 -> ModelProviderException.Kind.TIMEOUT;
default -> status >= 500 ? ModelProviderException.Kind.SERVER : ModelProviderException.Kind.OTHER;
};
return (new ModelProviderException(kind, status, "HTTP " + status + (type == null ? "" : " (" + type + ")")));
}
/*******************************************************************************
**
*******************************************************************************/
private static long number(Object value)
{
return (value instanceof Number n ? n.longValue() : 0);
}
/*******************************************************************************
** Never includes the key.
*******************************************************************************/
@Override
public String toString()
{
return ("OpenAiCompatibleModelProvider{name=" + name + ", baseUrl=" + baseUrl + ", apiKey=" + (apiKey == null ? "none" : "***") + "}");
}
/*******************************************************************************
** Builder.
*******************************************************************************/
public static class Builder
{
private String name = "openai";
private String baseUrl = "https://api.openai.com/v1";
private String apiKey;
private Duration requestTimeout = Duration.ofSeconds(120);
private Boolean strictTools = false;
/*******************************************************************************
** The provider's registration name.
*******************************************************************************/
public Builder name(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** e.g. https://api.openai.com/v1 or http://localhost:11434/v1
*******************************************************************************/
public Builder baseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
return (this);
}
/*******************************************************************************
** The key, or a reference such as "${env.OPENAI_API_KEY}".
*******************************************************************************/
public Builder apiKey(String apiKey)
{
this.apiKey = apiKey;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
public Builder requestTimeout(Duration requestTimeout)
{
this.requestTimeout = requestTimeout;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
public Builder strictTools(Boolean strictTools)
{
this.strictTools = strictTools;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
public OpenAiCompatibleModelProvider build()
{
if(!StringUtils.hasContent(name) || !StringUtils.hasContent(baseUrl))
{
throw (new IllegalArgumentException("name and baseUrl are required"));
}
return (new OpenAiCompatibleModelProvider(this));
}
}
}
@@ -248,6 +248,7 @@ public final class MarketingTools
c.put("lead_qualification", Boolean.TRUE.equals(campaign.getLeadQualificationEnabled())
? "on: at least " + campaign.getQualifyMinFormSubmissions() + " attributed form submission(s)" + (Boolean.TRUE.equals(campaign.getQualifyRequireEmailConsent()) ? " and email consent" : "")
: "off (no leads are qualified)");
c.put("lead_qualification_note", campaign.getLeadQualificationNote());
if(campaign.getAudienceId() != null)
{
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.marketing.content;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -39,8 +40,8 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Sets productId from campaignId on campaign-owned rows (metrics, outcome
** events, findings), so the product security lock (when configured) and
** product-level reports cover them. Outcome events also get quantity 1 when
** none is given.
** product-level reports cover them. Outcome events also get quantity 1, the
** current time and isTestData false when none is given.
*******************************************************************************/
public class ProductFromCampaignCustomizer implements TableCustomizerInterface
{
@@ -61,6 +62,22 @@ public class ProductFromCampaignCustomizer implements TableCustomizerInterface
record.setValue("quantity", 1);
}
}
if("marketing_outcome_event".equals(insertInput.getTableName()))
{
////////////////////////////////////////////////////////////////////
// an event always has a time (the daily rollup is by its UTC day) //
// and is real unless marked as test data //
////////////////////////////////////////////////////////////////////
if(record.getValue("occurredAt") == null)
{
record.setValue("occurredAt", Instant.now());
}
if(record.getValue("isTestData") == null)
{
record.setValue("isTestData", false);
}
blankActorToNull(record);
}
}
return (records);
}
@@ -86,6 +103,18 @@ public class ProductFromCampaignCustomizer implements TableCustomizerInterface
{
fill(record);
}
if("marketing_outcome_event".equals(updateInput.getTableName()))
{
if(record.getValues().containsKey("occurredAt") && record.getValue("occurredAt") == null)
{
record.addError(new BadInputStatusMessage("An outcome event needs the time it occurred."));
}
if(record.getValues().containsKey("isTestData") && record.getValue("isTestData") == null)
{
record.setValue("isTestData", false);
}
blankActorToNull(record);
}
if(findings)
{
guardBlocker(record, oldById.get(ValueUtils.getValueAsInteger(record.getValue("id"))));
@@ -127,6 +156,21 @@ public class ProductFromCampaignCustomizer implements TableCustomizerInterface
/*******************************************************************************
** A blank actor key is no actor: store null, so the rollup, the report and
** the backend's distinct count all agree.
*******************************************************************************/
private static void blankActorToNull(QRecord record)
{
String actor = record.getValueString("actorKey");
if(record.getValues().containsKey("actorKey") && actor != null && actor.isBlank())
{
record.setValue("actorKey", null);
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -40,21 +40,25 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
** compile dependency on the CRM and CRM permissions and locks apply.
**
** Rules it applies (from qbit-crm's own model):
** - Email consent: the contact's most recent crmConsentRecord of type Email
** - Email consent: the contact's most recent crm_consent_record of type Email
** decides (Granted, and not past its expiry date). No record means no
** consent: Marketing does not send unsolicited email.
** - Suppression: doNotEmail, an unsubscribeDate, or a list membership that
** is not Subscribed.
** - Activities are logged as qbit-crm "Note" activities.
**
** It uses qbit-crm 0.2.0's crm_* snake_case tables (no fallback to the
** 0.1.0 camelCase names).
*******************************************************************************/
public class QbitCrmGateway implements MarketingCrmGateway
{
public static final String CONTACT_TABLE = "crmContact";
public static final String CONSENT_TABLE = "crmConsentRecord";
public static final String LIST_MEMBER_TABLE = "crmEmailListMember";
public static final String FORM_SUBMISSION_TABLE = "crmFormSubmission";
public static final String ACTIVITY_TABLE = "crmActivity";
public static final String ACTIVITY_TYPE_TABLE = "crmActivityType";
public static final String CONTACT_TABLE = "crm_contact";
public static final String CONSENT_TABLE = "crm_consent_record";
public static final String LIST_MEMBER_TABLE = "crm_email_list_member";
public static final String FORM_SUBMISSION_TABLE = "crm_form_submission";
public static final String ACTIVITY_TABLE = "crm_activity";
public static final String ACTIVITY_TYPE_TABLE = "crm_activity_type";
static final Integer CONSENT_TYPE_EMAIL = 1;
static final Integer CONSENT_STATUS_GRANTED = 1;
@@ -0,0 +1,287 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.email;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qbits.email.api.EmailApiReadiness;
import com.kingsrook.qbits.email.api.EmailApiReadinessCheck;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.marketing.model.MarketingExecution;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Email through qbit-email: the durable outbox, the RFC 8058 unsubscribe
** link and headers, and suppression enforcement all live there.
**
** This is the ONLY class in this qbit that references qbit-email classes
** (the dependency is optional). Callers reach it only after
** isInstalled(...) is true; otherwise QqqMessagingEmailTransport is the
** fallback.
**
** Content: a campaign's content row holds a title and a plain body (no
** template), so each message is an ad-hoc Markdown email: subject = title,
** body = body plus the call to action as a link. No qbit-email template is
** registered (nothing here needs a variable schema).
**
** One message per recipient, category MARKETING, idempotency key
** marketing:CONTENT:APPROVAL:RECIPIENT-KEY (the execution's own stable key
** plus the recipient key, never an address), and the execution row as the
** linked record.
**
** Outcomes: QUEUED is ACCEPTED (in qbit-email's outbox, not delivered).
** SUPPRESSED is a skip, not a failure: the address opted out or bounced, so
** nothing is sent for it (the result says how many were skipped; if every
** recipient was suppressed the result is ACCEPTED with providerStatus
** all_suppressed). REFUSED is REJECTED with qbit-email's reasons. An
** exception is AMBIGUOUS, reconcilable through lookup (the idempotency key
** means a repeat can never send twice).
*******************************************************************************/
public class QbitEmailTransport implements MarketingTransport
{
private static final QLogger LOG = QLogger.getLogger(QbitEmailTransport.class);
public static final String DEFAULT_NAME = "qbit_email";
private static final String UNSUBSCRIBE_KEY_NAME = "QQQ_EMAIL_UNSUBSCRIBE_KEY";
private String name = DEFAULT_NAME;
/*******************************************************************************
** Is qbit-email on the classpath AND produced in this instance?
*******************************************************************************/
public static boolean isInstalled(QInstance qInstance)
{
try
{
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
return (EmailApi.isInstalled(qInstance));
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (name);
}
/*******************************************************************************
** OPERATIONAL when the qbit-email outbox is produced and marketing's opt-out
** prerequisites (public base URL and unsubscribe key) are configured; the
** provider itself is checked by qbit-email at send time and a problem there
** comes back as a plain REJECTED reason.
*******************************************************************************/
@Override
public TransportStatus getStatus()
{
if(!isInstalled(QContext.getQInstance()))
{
return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "The email qbit (qbit-email) is not installed in this application."));
}
try
{
EmailApiReadiness readiness = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING);
if(readiness.failed(EmailApiReadinessCheck.NOT_INSTALLED))
{
return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "The email qbit (qbit-email) is not installed in this application."));
}
if(readiness.failed(EmailApiReadinessCheck.PUBLIC_BASE_URL))
{
return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "Marketing email needs the email qbit's publicBaseUrl (the unsubscribe link base)."));
}
if(readiness.failed(EmailApiReadinessCheck.UNSUBSCRIBE_KEY))
{
return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "Marketing email needs the " + UNSUBSCRIBE_KEY_NAME + " secret (it signs the unsubscribe links)."));
}
}
catch(QException e)
{
LOG.warn("Could not check email qbit readiness", logPair("error", e.getClass().getSimpleName()));
return (new TransportStatus(TransportStatus.State.CONFIGURED, "Could not check the email qbit's configuration; retry when its configuration can be read."));
}
return (new TransportStatus(TransportStatus.State.OPERATIONAL, "Queues through the email qbit's outbox with its unsubscribe link and suppression list."));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Set<ChannelType> getSupportedChannelTypes()
{
return (EnumSet.of(ChannelType.EMAIL));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public TransportResult send(TransportRequest request)
{
TransportStatus status = getStatus();
if(!status.isUsable())
{
return (new TransportResult(TransportResult.Outcome.RETRYABLE_FAILURE, null, "not_configured", status.detail()));
}
int queued = 0;
int duplicates = 0;
int suppressed = 0;
for(Recipient recipient : request.recipients())
{
if(!StringUtils.hasContent(recipient.address()))
{
return (new TransportResult(queued + duplicates == 0 ? TransportResult.Outcome.REJECTED : TransportResult.Outcome.AMBIGUOUS, null, "no_address",
"A recipient has no address" + (queued + duplicates == 0 ? "." : " after " + (queued + duplicates) + " message(s) were queued.")));
}
try
{
EmailApiResult result = EmailApi.send(emailRequest(request, recipient));
switch(result.status())
{
case QUEUED -> queued++;
case DUPLICATE -> duplicates++;
case SUPPRESSED -> suppressed++;
default ->
{
String reasons = result.reasons() == null || result.reasons().isEmpty() ? "no reason given" : String.join("; ", result.reasons());
LOG.info("Email qbit refused a marketing message", logPair("executionId", request.executionId()), logPair("recipientKey", recipient.key()));
return (new TransportResult(TransportResult.Outcome.REJECTED, null, "refused",
"The email qbit refused the message: " + reasons + " (" + queued + " queued and " + suppressed + " suppressed before it)."));
}
}
}
catch(Exception e)
{
LOG.warn("Email qbit enqueue failed; outcome unknown", logPair("executionId", request.executionId()), logPair("recipientKey", recipient.key()),
logPair("error", e.getClass().getSimpleName()));
return (new TransportResult(TransportResult.Outcome.AMBIGUOUS, null, "error",
"The email qbit raised " + e.getClass().getSimpleName() + " after " + (queued + duplicates) + " of " + request.recipients().size()
+ " message(s); a repeat cannot send twice, and lookup can confirm what was queued."));
}
}
String receipt = "qbit-email:" + baseKey(request);
String summary = queued + " queued, " + suppressed + " skipped as suppressed, " + duplicates + " already queued (queued is not proof of delivery).";
if(queued == 0 && suppressed == 0)
{
return (new TransportResult(TransportResult.Outcome.DUPLICATE, receipt, "already_queued", summary));
}
return (new TransportResult(TransportResult.Outcome.ACCEPTED, receipt, queued == 0 ? "all_suppressed" : "queued", summary));
}
/*******************************************************************************
** Finds what qbit-email queued for the execution (any message under its key).
*******************************************************************************/
@Override
public TransportLookup lookup(String idempotencyKey)
{
try
{
String prefix = "marketing:" + stripPrefix(idempotencyKey) + ":";
List<EmailApiMessage> rows = EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1);
if(rows.isEmpty())
{
return (new TransportLookup(TransportLookup.State.NOT_FOUND, null));
}
return (new TransportLookup(TransportLookup.State.FOUND_ACCEPTED, "qbit-email:marketing:" + stripPrefix(idempotencyKey)));
}
catch(Exception e)
{
LOG.warn("Email qbit lookup failed", logPair("error", e.getClass().getSimpleName()));
return (new TransportLookup(TransportLookup.State.UNSUPPORTED, null));
}
}
/*******************************************************************************
** One MARKETING message to one recipient.
*******************************************************************************/
private EmailApiRequest emailRequest(TransportRequest request, Recipient recipient)
{
String body = request.body() == null ? "" : request.body();
if(StringUtils.hasContent(request.destinationUrl()) && request.destinationUrl().matches("(?i)https?://\\S+"))
{
String label = StringUtils.hasContent(request.callToAction()) ? request.callToAction().replaceAll("[\\[\\]\\r\\n]", " ").trim() : request.destinationUrl();
body = body + "\n\n[" + label + "](" + request.destinationUrl() + ")";
}
return (new EmailApiRequest()
.withTo(recipient.address())
.withSubject(request.title() == null ? "" : request.title().replaceAll("[\\r\\n]+", " ").trim())
.withMarkdownBody(body)
.withCategory(EmailApiCategory.MARKETING)
.withIdempotencyKey(baseKey(request) + ":" + recipient.key())
.withLinkedRecord(MarketingExecution.TABLE_NAME, request.executionId())
.withSender("marketing", "execution"));
}
/*******************************************************************************
** The per-execution part of the idempotency key.
*******************************************************************************/
private String baseKey(TransportRequest request)
{
return ("marketing:" + stripPrefix(request.idempotencyKey()));
}
/*******************************************************************************
** The execution key is marketing-execution:CONTENT:APPROVAL.
*******************************************************************************/
private static String stripPrefix(String key)
{
return (key == null ? "" : key.replaceFirst("^marketing-execution:", ""));
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.measurement;
import java.time.Instant;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
/*******************************************************************************
** One outcome reported by a MarketingOutcomeImporter.
**
** @param stage the funnel stage (required)
** @param eventType the event type, matching a metric of the measurement plan (required)
** @param externalEventId the importer's own id for the outcome, stable across fetches (required)
** @param actorKey an anonymous, stable actor id (null when unknown)
** @param occurredAt when it happened (null: when it was imported)
** @param quantity how many (null: 1)
** @param isTestData true for a fixture (kept out of the real numbers)
** @param note a short note (null for none)
*******************************************************************************/
public record ImportedOutcome(FunnelStage stage, String eventType, String externalEventId, String actorKey, Instant occurredAt, Integer quantity,
Boolean isTestData, String note)
{
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.measurement;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** A source of outcome events (an analytics export, a store listing, a package
** registry, ...) that the host registers with
** MarketingQBitConfig.withOutcomeImporter(...). The Import Outcomes campaign
** action runs every registered importer.
**
** The qbit ships no concrete network importer: there is no live analytics
** provider in 0.1.0, and nothing is "measured" until one records events.
**
** An importer only reports what it observed; Marketing stores each outcome
** through the normal insert path, so the (source, external id) dedupe applies:
** the source is the importer's name, and an outcome fetched again is dropped,
** not counted twice. So fetch may safely return events it returned before.
*******************************************************************************/
public interface MarketingOutcomeImporter
{
/*******************************************************************************
** The importer's name: unique among importers, and the "source" of every
** event it imports (so keep it stable). "marketing" and "crm" are reserved.
*******************************************************************************/
String getName();
/*******************************************************************************
** The outcomes of a campaign observed at or after since (null: all it can
** provide). Marketing passes the occurredAt of the newest event this
** importer already recorded for the campaign. Return an empty list when
** there is nothing. Never return personal data: the actorKey must be an
** anonymous, stable id.
*******************************************************************************/
List<ImportedOutcome> fetch(QRecord campaign, Instant since) throws QException;
}
@@ -18,20 +18,32 @@ package com.kingsrook.qbits.marketing.measurement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.marketing.MarketingStore;
import com.kingsrook.qbits.marketing.model.MarketingMetric;
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.model.enums.CountMode;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
import com.kingsrook.qbits.marketing.model.enums.InstrumentationStatus;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
@@ -46,6 +58,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
** - Stages with no metric say so.
** - Test data (controlled test transport, fixtures) is reported apart and
** never mixed into real numbers.
**
** The numbers come from the persisted daily rollup (marketing_metric_daily,
** kept by MeasurementRollupService) and from backend aggregates, never from
** loading the campaign's events: raw counts are summed from the rollup, and
** a campaign-wide unique-actor count (which cannot be summed from days) is a
** COUNT_DISTINCT of actorKey grouped by stage and event type, run in the
** backend.
*******************************************************************************/
public class MeasurementReport
{
@@ -89,21 +108,43 @@ public class MeasurementReport
{
MeasurementReport report = new MeasurementReport();
List<QRecord> metrics = MarketingStore.queryBy(MarketingMetric.TABLE_NAME, "campaignId", campaignId);
List<QRecord> events = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId);
List<QRecord> realEvents = new ArrayList<>();
for(QRecord event : events)
////////////////////////////////////////////////////////////////////////
// test events are counted apart; everything else is real data //
////////////////////////////////////////////////////////////////////////
report.testEvents = MarketingStore.count(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, true)));
QQueryFilter realEventFilter = new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, false));
//////////////////////////////////////////////////////////////////////////
// real events per stage and type (backend COUNT; for the totals and //
// for events outside the plan) //
//////////////////////////////////////////////////////////////////////////
Aggregate eventCount = new Aggregate("id", AggregateOperator.COUNT);
Map<String, AggregateResult> events = aggregate(MarketingOutcomeEvent.TABLE_NAME, "stage", realEventFilter, eventCount);
for(AggregateResult result : events.values())
{
if(Boolean.TRUE.equals(event.getValueBoolean("isTestData")))
{
report.testEvents++;
}
else
{
realEvents.add(event);
}
report.realEvents += asInt(result.getAggregateValue(eventCount));
}
report.realEvents = realEvents.size();
/////////////////////////////////////////////////////////////////////////
// raw counts and events without an actor: summed from the daily rollup //
/////////////////////////////////////////////////////////////////////////
Aggregate rawCount = new Aggregate("rawCount", AggregateOperator.SUM);
Aggregate eventsWithoutActor = new Aggregate("eventsWithoutActor", AggregateOperator.SUM);
Map<String, AggregateResult> rollup = aggregate(MarketingMetricDaily.TABLE_NAME, "funnelStage", new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, false)), rawCount, eventsWithoutActor);
///////////////////////////////////////////////////////////////////////////
// unique actors over the whole campaign: COUNT_DISTINCT in the backend //
// (a blank actor is no actor) //
///////////////////////////////////////////////////////////////////////////
Aggregate uniqueActors = new Aggregate("actorKey", AggregateOperator.COUNT_DISTINCT);
Map<String, AggregateResult> actors = aggregate(MarketingOutcomeEvent.TABLE_NAME, "stage", new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, false),
new QFilterCriteria("actorKey", QCriteriaOperator.IS_NOT_BLANK)), uniqueActors);
Set<FunnelStage> stagesWithMetrics = new HashSet<>();
Set<String> plannedTypes = new HashSet<>();
@@ -124,26 +165,12 @@ public class MeasurementReport
continue;
}
int raw = 0;
int noActor = 0;
Set<String> actors = new LinkedHashSet<>();
for(QRecord event : realEvents)
{
if(stage.getId().equals(event.getValueInteger("stage")) && metric.getEventType().equals(event.getValueString("eventType")))
{
Integer quantity = event.getValueInteger("quantity");
raw += quantity == null ? 1 : quantity;
String actor = event.getValueString("actorKey");
if(actor == null || actor.isBlank())
{
noActor++;
}
else
{
actors.add(actor);
}
}
}
String key = key(stage.getId(), metric.getEventType());
AggregateResult rollupRow = rollup.get(key);
AggregateResult actorRow = actors.get(key);
int raw = rollupRow == null ? 0 : asInt(rollupRow.getAggregateValue(rawCount));
int noActor = rollupRow == null ? 0 : asInt(rollupRow.getAggregateValue(eventsWithoutActor));
int actorsCount = actorRow == null ? 0 : asInt(actorRow.getAggregateValue(uniqueActors));
String note = instrumentation == InstrumentationStatus.PARTIAL ? "Partially instrumented: a lower bound." : null;
if(instrumentation == InstrumentationStatus.PARTIAL)
@@ -154,7 +181,7 @@ public class MeasurementReport
{
note = (note == null ? "" : note + " ") + noActor + " event(s) have no actor and are not in the unique count.";
}
report.rows.add(new Row(stage, metric.getName(), metric.getEventType(), countMode, instrumentation, metric.getTarget(), raw, actors.size(), noActor, note));
report.rows.add(new Row(stage, metric.getName(), metric.getEventType(), countMode, instrumentation, metric.getTarget(), raw, actorsCount, noActor, note));
}
for(FunnelStage stage : FunnelStage.values())
@@ -166,13 +193,14 @@ public class MeasurementReport
}
Map<String, Integer> unplanned = new LinkedHashMap<>();
for(QRecord event : realEvents)
for(Map.Entry<String, AggregateResult> entry : events.entrySet())
{
FunnelStage stage = FunnelStage.getById(event.getValueInteger("stage"));
String key = (stage == null ? "?" : stage.name()) + "/" + event.getValueString("eventType");
String[] parts = entry.getKey().split("\\|", 2);
FunnelStage stage = FunnelStage.getById(Integer.valueOf(parts[0]));
String key = (stage == null ? "?" : stage.name()) + "/" + parts[1];
if(!plannedTypes.contains(key))
{
unplanned.merge(key, 1, Integer::sum);
unplanned.merge(key, asInt(entry.getValue().getAggregateValue(eventCount)), Integer::sum);
}
}
unplanned.forEach((key, count) -> report.limitations.add(count + " recorded event(s) of type " + key + " have no metric in the measurement plan."));
@@ -188,6 +216,51 @@ public class MeasurementReport
/*******************************************************************************
** One backend aggregate over a table, grouped by its stage field and event
** type; the results by "stage|eventType".
*******************************************************************************/
private static Map<String, AggregateResult> aggregate(String tableName, String stageField, QQueryFilter filter, Aggregate... aggregates) throws QException
{
GroupBy stageGroup = new GroupBy(QFieldType.INTEGER, stageField);
GroupBy typeGroup = new GroupBy(QFieldType.STRING, "eventType");
AggregateInput input = new AggregateInput(tableName).withFilter(filter).withGroupBy(stageGroup).withGroupBy(typeGroup);
for(Aggregate aggregate : aggregates)
{
input.withAggregate(aggregate);
}
Map<String, AggregateResult> map = new HashMap<>();
for(AggregateResult result : new AggregateAction().execute(input).getResults())
{
map.put(key(ValueUtils.getValueAsInteger(result.getGroupByValue(stageGroup)), ValueUtils.getValueAsString(result.getGroupByValue(typeGroup))), result);
}
return (map);
}
/*******************************************************************************
**
*******************************************************************************/
private static String key(Integer stageId, String eventType)
{
return (stageId + "|" + eventType);
}
/*******************************************************************************
** An aggregate value as a count (null is none).
*******************************************************************************/
private static int asInt(java.io.Serializable value)
{
Integer integer = ValueUtils.getValueAsInteger(value);
return (integer == null ? 0 : integer);
}
/*******************************************************************************
** The report as plain data (for the agent's tool and the widget).
*******************************************************************************/
@@ -0,0 +1,305 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.measurement;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.marketing.MarketingStore;
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.security.MarketingInternalWrite;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Keeps the persisted daily rollup (marketing_metric_daily) of a campaign's
** outcome events, so a results view reads a few rows per day instead of every
** raw event.
**
** - A day is the UTC date of the event's occurredAt.
** - Recomputing a (campaign, day) reads that day's raw events and makes the
** rollup rows match them exactly: rows are inserted or updated by their
** unique key (campaign, day, stage, event type, test data) and rows whose
** events are gone are deleted. It is idempotent: running it again changes
** nothing (a row is rewritten only when its numbers differ).
** - Raw count is the sum of quantities; unique actors are the distinct
** actorKeys of that day (a campaign-wide unique count cannot be summed from
** days, so MeasurementReport counts it in the backend); events without an
** actor are counted apart, as in the report.
** - The rollup is system-written: only this service writes it, inside
** MarketingInternalWrite.
*******************************************************************************/
public class MeasurementRollupService
{
private static final int PAGE_SIZE = 1000;
/*******************************************************************************
** One campaign's day.
*******************************************************************************/
public record CampaignDay(Integer campaignId, LocalDate day)
{
}
/*******************************************************************************
** What a recompute did: the days it covered and the rollup rows they hold.
*******************************************************************************/
public record Result(int days, int rows)
{
}
/*******************************************************************************
** The sums of one (stage, test data, event type) of a day.
*******************************************************************************/
private static final class Sums
{
private int rawCount = 0;
private int eventsWithoutActor = 0;
private final Set<String> actors = new HashSet<>();
}
/*******************************************************************************
** The UTC day of an event: its occurredAt (the insert fills it in, so it is
** never null for a stored event; createDate is only a safety net).
*******************************************************************************/
public static LocalDate dayOf(QRecord event)
{
Instant occurredAt = event.getValueInstant("occurredAt");
if(occurredAt == null)
{
occurredAt = event.getValueInstant("createDate");
}
return (occurredAt == null ? null : occurredAt.atZone(ZoneOffset.UTC).toLocalDate());
}
/*******************************************************************************
** Recompute every given (campaign, day), as trusted internal work: this runs
** from the customizer of a write that was itself permitted, and from Rebuild
** Results after its own permission check.
*******************************************************************************/
public Result recompute(Collection<CampaignDay> campaignDays) throws QException
{
Set<CampaignDay> distinct = new LinkedHashSet<>();
for(CampaignDay campaignDay : campaignDays)
{
if(campaignDay.campaignId() != null && campaignDay.day() != null)
{
distinct.add(campaignDay);
}
}
return (MarketingStore.trusted(() -> MarketingInternalWrite.run(() ->
{
int rows = 0;
for(CampaignDay campaignDay : distinct)
{
rows += recomputeDay(campaignDay.campaignId(), campaignDay.day());
}
return (new Result(distinct.size(), rows));
})));
}
/*******************************************************************************
** Rebuild the whole rollup of a campaign: every day of its events, and every
** day of rollup rows that no longer have events. Returns a one-line summary.
** Needs the same permission as Update Results from CRM.
*******************************************************************************/
public String rebuild(Integer campaignId) throws QException
{
MarketingPermissions.require("rebuild results", MarketingPermissions.PREPARE);
MarketingStore.require(MarketingCampaign.TABLE_NAME, campaignId);
Result result = MarketingStore.trusted(() ->
{
Set<CampaignDay> days = new LinkedHashSet<>();
///////////////////////////////////////////////////////////////////////
// the days of the raw events, a page at a time (memory is bounded //
// by the number of distinct days, not the number of events) //
///////////////////////////////////////////////////////////////////////
int lastId = 0;
while(true)
{
List<QRecord> page = MarketingStore.query(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId))
.withOrderBy(new QFilterOrderBy("id")).withLimit(PAGE_SIZE));
for(QRecord event : page)
{
days.add(new CampaignDay(campaignId, dayOf(event)));
lastId = event.getValueInteger("id");
}
if(page.size() < PAGE_SIZE)
{
break;
}
}
for(QRecord existing : MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId))
{
days.add(new CampaignDay(campaignId, existing.getValueLocalDate("dayUtc")));
}
return (recompute(days));
});
return ("rebuilt " + result.rows() + " daily result row(s) over " + result.days() + " day(s)");
}
/*******************************************************************************
** Make one day's rollup rows match its raw events. Returns the number of rows
** the day has afterwards. Must run inside MarketingInternalWrite.
*******************************************************************************/
private int recomputeDay(Integer campaignId, LocalDate day) throws QException
{
Instant start = day.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant end = day.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
Map<String, Sums> sums = new LinkedHashMap<>();
for(QRecord event : MarketingStore.query(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
new QFilterCriteria("occurredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, start),
new QFilterCriteria("occurredAt", QCriteriaOperator.LESS_THAN, end))))
{
Sums sum = sums.computeIfAbsent(key(event.getValueInteger("stage"), Boolean.TRUE.equals(event.getValueBoolean("isTestData")), event.getValueString("eventType")), k -> new Sums());
Integer quantity = event.getValueInteger("quantity");
sum.rawCount += quantity == null ? 1 : quantity;
String actor = event.getValueString("actorKey");
if(actor == null || actor.isBlank())
{
sum.eventsWithoutActor++;
}
else
{
sum.actors.add(actor);
}
}
QRecord campaign = MarketingStore.get(MarketingCampaign.TABLE_NAME, campaignId);
Integer productId = campaign == null ? null : campaign.getValueInteger("productId");
Map<String, QRecord> existing = new LinkedHashMap<>();
for(QRecord row : MarketingStore.query(MarketingMetricDaily.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
new QFilterCriteria("dayUtc", QCriteriaOperator.EQUALS, day))))
{
existing.put(key(row.getValueInteger("funnelStage"), Boolean.TRUE.equals(row.getValueBoolean("isTestData")), row.getValueString("eventType")), row);
}
for(Map.Entry<String, Sums> entry : sums.entrySet())
{
String[] parts = entry.getKey().split("\\|", 3);
Sums sum = entry.getValue();
QRecord row = existing.remove(entry.getKey());
if(row == null)
{
upsertNew(new MarketingMetricDaily().withCampaignId(campaignId).withProductId(productId).withDayUtc(day)
.withFunnelStage(Integer.valueOf(parts[0])).withIsTestData(Boolean.valueOf(parts[1])).withEventType(parts[2])
.withRawCount(sum.rawCount).withUniqueActors(sum.actors.size()).withEventsWithoutActor(sum.eventsWithoutActor).withRecomputedAt(Instant.now()));
}
else if(!Objects.equals(productId, row.getValueInteger("productId")) || !Objects.equals(sum.rawCount, row.getValueInteger("rawCount"))
|| !Objects.equals(sum.actors.size(), row.getValueInteger("uniqueActors")) || !Objects.equals(sum.eventsWithoutActor, row.getValueInteger("eventsWithoutActor")))
{
MarketingStore.update(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("productId", productId)
.withValue("rawCount", sum.rawCount).withValue("uniqueActors", sum.actors.size()).withValue("eventsWithoutActor", sum.eventsWithoutActor)
.withValue("recomputedAt", Instant.now()));
}
}
///////////////////////////////////
// rows whose events are gone //
///////////////////////////////////
List<Serializable> stale = new ArrayList<>();
existing.values().forEach(row -> stale.add(row.getValue("id")));
MarketingStore.delete(MarketingMetricDaily.TABLE_NAME, stale);
return (sums.size());
}
/*******************************************************************************
** Insert a new rollup row. If a concurrent recompute inserted the same key
** first (the unique key rejects ours), update that row instead.
*******************************************************************************/
private void upsertNew(MarketingMetricDaily row) throws QException
{
try
{
MarketingStore.insert(row);
}
catch(QUserFacingException e)
{
List<QRecord> winners = MarketingStore.query(MarketingMetricDaily.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, row.getCampaignId()),
new QFilterCriteria("dayUtc", QCriteriaOperator.EQUALS, row.getDayUtc()),
new QFilterCriteria("funnelStage", QCriteriaOperator.EQUALS, row.getFunnelStage()),
new QFilterCriteria("eventType", QCriteriaOperator.EQUALS, row.getEventType()),
new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, row.getIsTestData())));
if(winners.isEmpty())
{
throw (e);
}
MarketingStore.update(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", winners.get(0).getValue("id")).withValue("productId", row.getProductId())
.withValue("rawCount", row.getRawCount()).withValue("uniqueActors", row.getUniqueActors()).withValue("eventsWithoutActor", row.getEventsWithoutActor())
.withValue("recomputedAt", row.getRecomputedAt()));
}
}
/*******************************************************************************
** The map key of a rollup row: stage, test data, then the event type (last,
** so the type may contain the separator).
*******************************************************************************/
private static String key(Integer stage, boolean isTestData, String eventType)
{
return (stage + "|" + isTestData + "|" + eventType);
}
}
@@ -0,0 +1,128 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.measurement;
import java.io.Serializable;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.marketing.MarketingStore;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** Keeps the daily rollup in step with the raw outcome events: after every
** insert, update and delete of events - from the table, the QQQ API, the CRM
** import, an importer or the execution worker - it recomputes the affected
** (campaign, day)s (MeasurementRollupService). An update recomputes both the
** old and the new day, so an event moved to another day or campaign leaves
** nothing behind.
*******************************************************************************/
public class OutcomeEventRollupCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
{
Set<MeasurementRollupService.CampaignDay> days = new LinkedHashSet<>();
for(QRecord record : CollectionUtils.nonNullList(records))
{
if(!CollectionUtils.nullSafeHasContents(record.getErrors()))
{
add(days, record);
}
}
new MeasurementRollupService().recompute(days);
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
{
Map<Serializable, QRecord> oldById = oldRecordListToMap("id", oldRecordList).orElse(Map.of());
Set<MeasurementRollupService.CampaignDay> days = new LinkedHashSet<>();
for(QRecord record : CollectionUtils.nonNullList(records))
{
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
{
continue;
}
//////////////////////////////////////////////////////////////////////
// the stored row after the update (the record may carry only the //
// changed fields), and the row as it was //
//////////////////////////////////////////////////////////////////////
add(days, MarketingStore.get(MarketingOutcomeEvent.TABLE_NAME, record.getValue("id")));
add(days, oldById.get(record.getValue("id")));
}
new MeasurementRollupService().recompute(days);
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
{
Set<MeasurementRollupService.CampaignDay> days = new LinkedHashSet<>();
for(QRecord record : CollectionUtils.nonNullList(records))
{
if(!CollectionUtils.nullSafeHasContents(record.getErrors()))
{
add(days, record);
}
}
new MeasurementRollupService().recompute(days);
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
private static void add(Set<MeasurementRollupService.CampaignDay> days, QRecord event)
{
if(event != null && event.getValueInteger("campaignId") != null && MeasurementRollupService.dayOf(event) != null)
{
days.add(new MeasurementRollupService.CampaignDay(event.getValueInteger("campaignId"), MeasurementRollupService.dayOf(event)));
}
}
}
@@ -0,0 +1,172 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.measurement;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.MarketingStore;
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Runs a campaign's registered MarketingOutcomeImporters and stores what they
** report as outcome events, through the normal insert path: the source is the
** importer's name, so an outcome fetched twice is stored once (the unique
** (source, external id)). The daily rollup follows from the insert.
**
** One failing importer does not stop the others; the summary names it and the
** action reports the campaign as not fully done.
*******************************************************************************/
public class OutcomeImportService
{
private static final QLogger LOG = QLogger.getLogger(OutcomeImportService.class);
/*******************************************************************************
** Why nothing can be imported (no importer is registered), or null.
*******************************************************************************/
public String blocker() throws QException
{
return (MarketingRuntime.current().getConfig().getOutcomeImporters().isEmpty() ? "no outcome importer is registered" : null);
}
/*******************************************************************************
** Entry point: runs as trusted internal work after its permission check.
** Returns a one-line summary.
*******************************************************************************/
public String importOutcomes(Integer campaignId) throws QException
{
MarketingPermissions.require("import outcomes", MarketingPermissions.PREPARE);
return (MarketingStore.trusted(() -> importOutcomesImpl(campaignId)));
}
/*******************************************************************************
**
*******************************************************************************/
private String importOutcomesImpl(Integer campaignId) throws QException
{
List<MarketingOutcomeImporter> importers = MarketingRuntime.current().getConfig().getOutcomeImporters();
if(importers.isEmpty())
{
throw (new QUserFacingException("No outcome importer is registered in this application."));
}
QRecord campaign = MarketingStore.require(MarketingCampaign.TABLE_NAME, campaignId);
List<String> parts = new ArrayList<>();
boolean failed = false;
for(MarketingOutcomeImporter importer : importers)
{
try
{
parts.add(importer.getName() + ": " + importOne(importer, campaign));
}
catch(QException | RuntimeException e)
{
failed = true;
LOG.warn("Outcome importer failed", e, logPair("importer", importer.getName()), logPair("campaignId", campaignId));
parts.add(importer.getName() + ": failed (" + (e instanceof QUserFacingException ? e.getMessage() : "see the server log") + ")");
}
}
String summary = String.join("; ", parts);
if(failed)
{
throw (new QUserFacingException(summary));
}
return (summary);
}
/*******************************************************************************
**
*******************************************************************************/
private String importOne(MarketingOutcomeImporter importer, QRecord campaign) throws QException
{
Integer campaignId = campaign.getValueInteger("id");
List<ImportedOutcome> outcomes = importer.fetch(campaign, newestOccurredAt(importer.getName(), campaignId));
int imported = 0;
int duplicates = 0;
int invalid = 0;
for(ImportedOutcome outcome : outcomes == null ? List.<ImportedOutcome>of() : outcomes)
{
if(outcome == null || outcome.stage() == null || !StringUtils.hasContent(outcome.eventType()) || !StringUtils.hasContent(outcome.externalEventId()))
{
invalid++;
continue;
}
if(MarketingStore.count(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("source", QCriteriaOperator.EQUALS, importer.getName()),
new QFilterCriteria("externalEventId", QCriteriaOperator.EQUALS, outcome.externalEventId()))) > 0)
{
duplicates++;
continue;
}
MarketingStore.insert(new MarketingOutcomeEvent()
.withCampaignId(campaignId)
.withStage(outcome.stage().getId())
.withEventType(outcome.eventType())
.withSource(importer.getName())
.withExternalEventId(outcome.externalEventId())
.withActorKey(outcome.actorKey())
.withOccurredAt(outcome.occurredAt() == null ? Instant.now() : outcome.occurredAt())
.withQuantity(outcome.quantity() == null ? 1 : outcome.quantity())
.withIsTestData(Boolean.TRUE.equals(outcome.isTestData()))
.withNote(outcome.note()));
imported++;
}
return (imported + " imported, " + duplicates + " already recorded" + (invalid > 0 ? ", " + invalid + " invalid (skipped)" : ""));
}
/*******************************************************************************
** When the newest event this importer recorded for the campaign occurred, or
** null when it has recorded none.
*******************************************************************************/
private static Instant newestOccurredAt(String importerName, Integer campaignId) throws QException
{
List<QRecord> newest = MarketingStore.query(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
new QFilterCriteria("source", QCriteriaOperator.EQUALS, importerName))
.withOrderBy(new QFilterOrderBy("occurredAt", false)).withLimit(1));
return (newest.isEmpty() ? null : newest.get(0).getValueInstant("occurredAt"));
}
}
@@ -26,6 +26,7 @@ import java.util.Set;
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
import com.kingsrook.qbits.marketing.content.MarketingContentCustomizer;
import com.kingsrook.qbits.marketing.content.ProductFromCampaignCustomizer;
import com.kingsrook.qbits.marketing.measurement.OutcomeEventRollupCustomizer;
import com.kingsrook.qbits.marketing.model.MarketingAgentRun;
import com.kingsrook.qbits.marketing.model.MarketingAgentStep;
import com.kingsrook.qbits.marketing.model.MarketingApproval;
@@ -40,6 +41,7 @@ import com.kingsrook.qbits.marketing.model.MarketingExecution;
import com.kingsrook.qbits.marketing.model.MarketingExecutionAttempt;
import com.kingsrook.qbits.marketing.model.MarketingFinding;
import com.kingsrook.qbits.marketing.model.MarketingMetric;
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.model.MarketingPolicy;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
@@ -108,11 +110,12 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** - The nav (the Marketing app) lists only the main tables - products,
** campaigns, content, executions, agent runs - and the settings tables.
** Sub-tables (audiences, evidence, claims, revisions, approvals, attempts,
** test deliveries, agent steps, findings, metrics, outcome events) appear
** test deliveries, agent steps, findings, metrics, outcome events, daily
** results) appear
** as child lists on their parents; they stay reachable by URL.
** - System-written tables (revisions, approvals, executions, attempts, test
** deliveries, agent runs, agent steps) are read-only in the UI and API and
** guarded by MarketingInternalWrite.
** deliveries, agent runs, agent steps, daily results) are read-only in the
** UI and API and guarded by MarketingInternalWrite.
*******************************************************************************/
public class MarketingMetaDataBuilder
{
@@ -128,7 +131,7 @@ public class MarketingMetaDataBuilder
/** Written only by Marketing's services. */
public static final Set<String> SYSTEM_WRITTEN_TABLE_NAMES = Set.of(MarketingContentRevision.TABLE_NAME, MarketingApproval.TABLE_NAME,
MarketingExecution.TABLE_NAME, MarketingExecutionAttempt.TABLE_NAME, MarketingTestDelivery.TABLE_NAME,
MarketingAgentRun.TABLE_NAME, MarketingAgentStep.TABLE_NAME);
MarketingAgentRun.TABLE_NAME, MarketingAgentStep.TABLE_NAME, MarketingMetricDaily.TABLE_NAME);
private final MarketingQBitConfig config;
@@ -199,6 +202,8 @@ public class MarketingMetaDataBuilder
List.of("id", "campaignId", "name", "stage", "eventType", "countMode", "instrumentationStatus", "target"), false),
new TableSpec(MarketingOutcomeEvent.TABLE_NAME, "Marketing Outcome Event", "insights", MarketingOutcomeEvent.class, "%s %s", List.of("eventType", "externalEventId"),
List.of("id", "campaignId", "stage", "eventType", "source", "externalEventId", "occurredAt", "isTestData"), false),
new TableSpec(MarketingMetricDaily.TABLE_NAME, "Marketing Metric Daily", "calendar_view_day", MarketingMetricDaily.class, "%s %s", List.of("dayUtc", "eventType"),
List.of("id", "campaignId", "dayUtc", "funnelStage", "eventType", "isTestData", "rawCount", "uniqueActors", "eventsWithoutActor"), false),
new TableSpec(MarketingPolicy.TABLE_NAME, "Marketing Policy", "policy", MarketingPolicy.class, "%s", List.of("name"),
List.of("id", "name", "agentEnabled", "executionEnabled", "providerName", "model"), true)
));
@@ -246,6 +251,7 @@ public class MarketingMetaDataBuilder
child(MarketingCampaign.TABLE_NAME, MarketingFinding.TABLE_NAME, "campaignId", "Findings", false);
child(MarketingCampaign.TABLE_NAME, MarketingAgentRun.TABLE_NAME, "campaignId", "Agent Runs", false);
child(MarketingCampaign.TABLE_NAME, MarketingOutcomeEvent.TABLE_NAME, "campaignId", "Outcome Events", true);
child(MarketingCampaign.TABLE_NAME, MarketingMetricDaily.TABLE_NAME, "campaignId", "Daily Results", false);
sectionWidget(MarketingContent.TABLE_NAME, MarketingWidgets.CONTENT_PROPOSAL, "Proposed Publication");
child(MarketingContent.TABLE_NAME, MarketingContentRevision.TABLE_NAME, "contentId", "Revision History", false);
@@ -383,6 +389,7 @@ public class MarketingMetaDataBuilder
case MarketingContentRevision.TABLE_NAME -> table.withUniqueKey(new UniqueKey("contentId", "revisionNumber"));
case MarketingExecution.TABLE_NAME -> table.withUniqueKey(new UniqueKey("idempotencyKey"));
case MarketingOutcomeEvent.TABLE_NAME -> table.withUniqueKey(new UniqueKey("source", "externalEventId"));
case MarketingMetricDaily.TABLE_NAME -> table.withUniqueKey(new UniqueKey("campaignId", "dayUtc", "funnelStage", "eventType", "isTestData"));
case MarketingTestDelivery.TABLE_NAME -> table.withUniqueKey(new UniqueKey("idempotencyKey", "recipientKey"));
default ->
{
@@ -417,6 +424,17 @@ public class MarketingMetaDataBuilder
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, product);
}
if(MarketingOutcomeEvent.TABLE_NAME.equals(spec.name()))
{
////////////////////////////////////////////////////////////////
// the daily rollup follows every insert, edit and delete //
////////////////////////////////////////////////////////////////
QCodeReference rollup = new QCodeReference(OutcomeEventRollupCustomizer.class);
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, rollup);
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, rollup);
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, rollup);
}
////////////////////////////////////////////////////////////////////
// the optional product (tenant) boundary: a record lock on the //
// product, for products and every table that carries productId //
@@ -68,6 +68,9 @@ public class MarketingAgentRun extends QRecordEntity
@QField()
private Instant startedAt;
@QField()
private Instant heartbeatAt;
@QField()
private Instant finishedAt;
@@ -460,6 +463,36 @@ public class MarketingAgentRun extends QRecordEntity
}
/*******************************************************************************
** Getter for heartbeatAt
*******************************************************************************/
public Instant getHeartbeatAt()
{
return (this.heartbeatAt);
}
/*******************************************************************************
** Setter for heartbeatAt
*******************************************************************************/
public void setHeartbeatAt(Instant heartbeatAt)
{
this.heartbeatAt = heartbeatAt;
}
/*******************************************************************************
** Fluent setter for heartbeatAt
*******************************************************************************/
public MarketingAgentRun withHeartbeatAt(Instant heartbeatAt)
{
this.heartbeatAt = heartbeatAt;
return (this);
}
/*******************************************************************************
** Getter for finishedAt
*******************************************************************************/
@@ -0,0 +1,486 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.model;
import java.time.Instant;
import java.time.LocalDate;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
/*******************************************************************************
** A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).
** Table marketing_metric_daily.
*******************************************************************************/
public class MarketingMetricDaily extends QRecordEntity
{
public static final String TABLE_NAME = "marketing_metric_daily";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, possibleValueSourceName = MarketingCampaign.TABLE_NAME)
private Integer campaignId;
@QField(possibleValueSourceName = MarketingProduct.TABLE_NAME)
private Integer productId;
@QField(isRequired = true)
private LocalDate dayUtc;
@QField(isRequired = true, possibleValueSourceName = FunnelStage.NAME)
private Integer funnelStage;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, isRequired = true)
private String eventType;
@QField(isRequired = true)
private Boolean isTestData;
@QField()
private Integer rawCount;
@QField()
private Integer uniqueActors;
@QField()
private Integer eventsWithoutActor;
@QField()
private Instant recomputedAt;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public MarketingMetricDaily()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public MarketingMetricDaily(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public MarketingMetricDaily withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for campaignId
*******************************************************************************/
public Integer getCampaignId()
{
return (this.campaignId);
}
/*******************************************************************************
** Setter for campaignId
*******************************************************************************/
public void setCampaignId(Integer campaignId)
{
this.campaignId = campaignId;
}
/*******************************************************************************
** Fluent setter for campaignId
*******************************************************************************/
public MarketingMetricDaily withCampaignId(Integer campaignId)
{
this.campaignId = campaignId;
return (this);
}
/*******************************************************************************
** Getter for productId
*******************************************************************************/
public Integer getProductId()
{
return (this.productId);
}
/*******************************************************************************
** Setter for productId
*******************************************************************************/
public void setProductId(Integer productId)
{
this.productId = productId;
}
/*******************************************************************************
** Fluent setter for productId
*******************************************************************************/
public MarketingMetricDaily withProductId(Integer productId)
{
this.productId = productId;
return (this);
}
/*******************************************************************************
** Getter for dayUtc
*******************************************************************************/
public LocalDate getDayUtc()
{
return (this.dayUtc);
}
/*******************************************************************************
** Setter for dayUtc
*******************************************************************************/
public void setDayUtc(LocalDate dayUtc)
{
this.dayUtc = dayUtc;
}
/*******************************************************************************
** Fluent setter for dayUtc
*******************************************************************************/
public MarketingMetricDaily withDayUtc(LocalDate dayUtc)
{
this.dayUtc = dayUtc;
return (this);
}
/*******************************************************************************
** Getter for funnelStage
*******************************************************************************/
public Integer getFunnelStage()
{
return (this.funnelStage);
}
/*******************************************************************************
** Setter for funnelStage
*******************************************************************************/
public void setFunnelStage(Integer funnelStage)
{
this.funnelStage = funnelStage;
}
/*******************************************************************************
** Fluent setter for funnelStage
*******************************************************************************/
public MarketingMetricDaily withFunnelStage(Integer funnelStage)
{
this.funnelStage = funnelStage;
return (this);
}
/*******************************************************************************
** Getter for eventType
*******************************************************************************/
public String getEventType()
{
return (this.eventType);
}
/*******************************************************************************
** Setter for eventType
*******************************************************************************/
public void setEventType(String eventType)
{
this.eventType = eventType;
}
/*******************************************************************************
** Fluent setter for eventType
*******************************************************************************/
public MarketingMetricDaily withEventType(String eventType)
{
this.eventType = eventType;
return (this);
}
/*******************************************************************************
** Getter for isTestData
*******************************************************************************/
public Boolean getIsTestData()
{
return (this.isTestData);
}
/*******************************************************************************
** Setter for isTestData
*******************************************************************************/
public void setIsTestData(Boolean isTestData)
{
this.isTestData = isTestData;
}
/*******************************************************************************
** Fluent setter for isTestData
*******************************************************************************/
public MarketingMetricDaily withIsTestData(Boolean isTestData)
{
this.isTestData = isTestData;
return (this);
}
/*******************************************************************************
** Getter for rawCount
*******************************************************************************/
public Integer getRawCount()
{
return (this.rawCount);
}
/*******************************************************************************
** Setter for rawCount
*******************************************************************************/
public void setRawCount(Integer rawCount)
{
this.rawCount = rawCount;
}
/*******************************************************************************
** Fluent setter for rawCount
*******************************************************************************/
public MarketingMetricDaily withRawCount(Integer rawCount)
{
this.rawCount = rawCount;
return (this);
}
/*******************************************************************************
** Getter for uniqueActors
*******************************************************************************/
public Integer getUniqueActors()
{
return (this.uniqueActors);
}
/*******************************************************************************
** Setter for uniqueActors
*******************************************************************************/
public void setUniqueActors(Integer uniqueActors)
{
this.uniqueActors = uniqueActors;
}
/*******************************************************************************
** Fluent setter for uniqueActors
*******************************************************************************/
public MarketingMetricDaily withUniqueActors(Integer uniqueActors)
{
this.uniqueActors = uniqueActors;
return (this);
}
/*******************************************************************************
** Getter for eventsWithoutActor
*******************************************************************************/
public Integer getEventsWithoutActor()
{
return (this.eventsWithoutActor);
}
/*******************************************************************************
** Setter for eventsWithoutActor
*******************************************************************************/
public void setEventsWithoutActor(Integer eventsWithoutActor)
{
this.eventsWithoutActor = eventsWithoutActor;
}
/*******************************************************************************
** Fluent setter for eventsWithoutActor
*******************************************************************************/
public MarketingMetricDaily withEventsWithoutActor(Integer eventsWithoutActor)
{
this.eventsWithoutActor = eventsWithoutActor;
return (this);
}
/*******************************************************************************
** Getter for recomputedAt
*******************************************************************************/
public Instant getRecomputedAt()
{
return (this.recomputedAt);
}
/*******************************************************************************
** Setter for recomputedAt
*******************************************************************************/
public void setRecomputedAt(Instant recomputedAt)
{
this.recomputedAt = recomputedAt;
}
/*******************************************************************************
** Fluent setter for recomputedAt
*******************************************************************************/
public MarketingMetricDaily withRecomputedAt(Instant recomputedAt)
{
this.recomputedAt = recomputedAt;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public MarketingMetricDaily withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (this.modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public MarketingMetricDaily withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -26,6 +26,8 @@ import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
import com.kingsrook.qbits.marketing.approval.ApprovalService;
import com.kingsrook.qbits.marketing.execution.ExecutionService;
import com.kingsrook.qbits.marketing.measurement.CrmResultsService;
import com.kingsrook.qbits.marketing.measurement.MeasurementRollupService;
import com.kingsrook.qbits.marketing.measurement.OutcomeImportService;
import com.kingsrook.qbits.marketing.model.MarketingAgentRun;
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
import com.kingsrook.qbits.marketing.model.MarketingContent;
@@ -57,6 +59,8 @@ public final class MarketingActions
public static final String AGENT_REVIEW = "marketing_agent_review";
public static final String AGENT_ANALYZE = "marketing_agent_analyze";
public static final String UPDATE_RESULTS_FROM_CRM = "marketing_update_results_from_crm";
public static final String REBUILD_RESULTS = "marketing_rebuild_results";
public static final String IMPORT_OUTCOMES = "marketing_import_outcomes";
public static final String SUBMIT_FOR_REVIEW = "marketing_submit_for_review";
public static final String APPROVE = "marketing_approve";
public static final String RETURN_TO_DRAFT = "marketing_return_to_draft";
@@ -173,6 +177,12 @@ public final class MarketingActions
"campaign(s) will import CRM form submissions and apply their lead rule",
r -> r.getValueString("utmCampaign") == null || r.getValueString("utmCampaign").isBlank() ? "no UTM campaign set" : null,
r -> new CrmResultsService().updateFromCrm(r.getValueInteger("id"))));
actions.add(new SimpleAction(REBUILD_RESULTS, "Rebuild Results", MarketingCampaign.TABLE_NAME, "calendar_view_day", MarketingPermissions.PREPARE_BASE,
"campaign(s) will have their daily results rebuilt from the raw outcome events", r -> null,
r -> new MeasurementRollupService().rebuild(r.getValueInteger("id"))));
actions.add(new SimpleAction(IMPORT_OUTCOMES, "Import Outcomes", MarketingCampaign.TABLE_NAME, "cloud_download", MarketingPermissions.PREPARE_BASE,
"campaign(s) will import outcome events from the registered importers (repeats are dropped)", r -> new OutcomeImportService().blocker(),
r -> new OutcomeImportService().importOutcomes(r.getValueInteger("id"))));
//////////////
// content //
@@ -152,6 +152,7 @@ public class ReadinessChecker
MarketingProduct product = new MarketingProduct(MarketingStore.require(MarketingProduct.TABLE_NAME, campaign.getProductId()));
Integer pid = campaign.getProductId();
findings.clear();
int channelRulesStaleDays = MarketingRuntime.current().getConfig().getStaleChannelRulesDays();
ProductMaturity maturity = ProductMaturity.getById(product.getMaturity());
CampaignGoal goal = CampaignGoal.getById(campaign.getGoal());
@@ -203,11 +204,24 @@ public class ReadinessChecker
{
continue;
}
String rulesUrl = channel.getValueString("rulesSourceUrl");
String readIt = StringUtils.hasContent(rulesUrl) ? "Read " + rulesUrl + " and record" : "Read the channel's current rules and record";
if(AiContentPolicy.UNKNOWN.getId().equals(channel.getValueInteger("aiContentPolicy")) || channel.getValue("rulesVerifiedDate") == null)
{
add(pid, campaignId, contentRecord.getValueInteger("id"), null, FindingType.CHANNEL_RULE, FindingSeverity.WARNING,
"Channel rules not verified: " + channel.getValueString("name"),
"The channel's rules (including on AI-generated content and automation) are not recorded as verified.", "Read the channel's current rules and record them on the channel.");
"The channel's rules (including on AI-generated content and automation) are not recorded as verified.", readIt + " them on the channel.");
}
else if(isStale(channel.getValueLocalDate("rulesVerifiedDate"), channelRulesStaleDays))
{
////////////////////////////////////////////////////////////////////////
// same idea as a stale evidence source: rules change, so a check //
// older than staleChannelRulesDays has to be repeated //
////////////////////////////////////////////////////////////////////////
add(pid, campaignId, contentRecord.getValueInteger("id"), null, FindingType.CHANNEL_RULE, FindingSeverity.WARNING,
"Channel rules may be out of date: " + channel.getValueString("name"),
"The channel's rules were last verified " + channel.getValueLocalDate("rulesVerifiedDate") + " (over " + channelRulesStaleDays + " days ago).",
readIt + " the date, and change the channel's AI-content and automation settings if its rules changed.");
}
}
@@ -0,0 +1,122 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.security;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Gates a widget on one exact permission name (marketing.read for the home
** widgets, marketing.admin.read for the admin-placeable ones). QQQ's widget
** rules can only express "<base>.hasAccess", so this checker is how a widget
** requires a read permission. Denied widgets are HIDDEN: omitted from the
** dashboard rather than shown locked.
*******************************************************************************/
public class MarketingPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
{
public static final String PERMISSION_PROPERTY = "permissionName";
private String permissionName;
/*******************************************************************************
** Permission rules requiring the named permission; hidden when missing.
*******************************************************************************/
public static QPermissionRules rules(String permissionName)
{
return (new QPermissionRules()
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withDenyBehavior(DenyBehavior.HIDDEN)
.withCustomPermissionChecker(new QCodeReferenceWithProperties(MarketingPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName))));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void initialize(QCodeReference codeReference)
{
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
{
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
}
}
/*******************************************************************************
** Deny unless the session holds the permission (fail closed). The system
** user (scheduler) passes.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session instanceof QSystemUserSession)
{
return;
}
if(permissionName == null || session == null || !session.hasPermission(permissionName))
{
throw (new QPermissionDeniedException("Permission denied."));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean handlesBuildAvailablePermission()
{
return (true);
}
/*******************************************************************************
** The exact permission name.
*******************************************************************************/
@Override
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
{
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
return (permission == null || permissionName == null ? null : permission.withName(permissionName));
}
}
@@ -19,10 +19,12 @@ package com.kingsrook.qbits.marketing.widgets;
import java.io.Serializable;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.MarketingStore;
@@ -46,11 +48,14 @@ import com.kingsrook.qbits.marketing.model.enums.ContentStatus;
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.security.MarketingPermissionChecker;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -66,21 +71,26 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** The Marketing widgets: the app home dashboard (the one Marketing
** overview - other apps only link to it) and two record widgets.
**
** - Home: Needs Attention, Campaigns, Agent Activity, Configuration Status.
** - Home: Needs Attention, Campaigns (business content only).
** - Admin-placeable (not on the home): Agent Activity (model/tool calls and
** tokens) and Configuration Status (provider configuration) are system
** content; a host places ADMIN_WIDGETS on its Admin area. They need
** marketing.admin.read.
** - Campaign record: Results (per stage; "Not measured" is never shown as 0).
** - Content record: Proposed Publication (the exact text, destination,
** recipients and schedule a reviewer approves).
**
** All are full-width tables of at most three columns (or one HTML block),
** so they read at every width without inner scrolling. All data is real;
** with none, they say so. Renderers require marketing.read and read with
** the viewer's record locks.
** with none, they say so. Home widgets require marketing.read (omitted, never
** shown locked, without it) and read with the viewer's record locks.
*******************************************************************************/
public final class MarketingWidgets
{
@@ -91,7 +101,14 @@ public final class MarketingWidgets
public static final String CAMPAIGN_RESULTS = "marketing_campaign_results";
public static final String CONTENT_PROPOSAL = "marketing_content_proposal";
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a z", Locale.US);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS);
/////////////////////////////////////////////////////////////////////////
// system content: for a host to place on its Admin area, not the home //
/////////////////////////////////////////////////////////////////////////
public static final List<String> ADMIN_WIDGETS = List.of(AGENT_ACTIVITY, CONFIGURATION);
@@ -110,12 +127,12 @@ public final class MarketingWidgets
public static List<QWidgetMetaData> buildWidgets()
{
return (List.of(
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class),
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class),
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class),
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class),
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class),
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class)));
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class, MarketingPermissions.READ),
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class, MarketingPermissions.READ),
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class, MarketingPermissions.ADMIN_READ),
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class, MarketingPermissions.ADMIN_READ),
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class, MarketingPermissions.READ),
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class, MarketingPermissions.READ)));
}
@@ -123,7 +140,7 @@ public final class MarketingWidgets
/*******************************************************************************
**
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer, String permission)
{
return (new QWidgetMetaData()
.withName(name)
@@ -131,7 +148,7 @@ public final class MarketingWidgets
.withType(type.getType())
.withGridColumns(12)
.withIsCard(true)
.withPermissionRules(MarketingPermissions.hasAccessRules(MarketingPermissions.BASE))
.withPermissionRules(MarketingPermissionChecker.rules(permission))
.withCodeReference(new QCodeReference(renderer)));
}
@@ -142,16 +159,26 @@ public final class MarketingWidgets
*******************************************************************************/
abstract static class Base extends AbstractWidgetRenderer
{
/*******************************************************************************
** The permission this widget needs (rendering is refused without it, behind
** the widget's own rules: no "you need ..." text is ever returned).
*******************************************************************************/
String requiredPermission()
{
return (MarketingPermissions.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
QSession session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(MarketingPermissions.READ)))
if(!MarketingPermissions.has(requiredPermission()))
{
return (new RenderWidgetOutput(new RawHTML(null, "<p>You need " + MarketingPermissions.READ + " to see marketing data.</p>")));
throw (new QPermissionDeniedException("Permission denied."));
}
return (renderFor(input));
}
@@ -196,6 +223,19 @@ public final class MarketingWidgets
/*******************************************************************************
** A date-time for people, in the user's time zone (the session's, else the
** instance's), as the record screens show it: "Oct 14, 2026 11:53 AM CDT"
** (empty when null).
*******************************************************************************/
static String when(Object value)
{
Instant instant = ValueUtils.getValueAsInstant(value);
return (instant == null ? "" : WHEN_FORMAT.withZone(ValueUtils.getSessionOrInstanceZoneId()).format(instant));
}
/*******************************************************************************
**
*******************************************************************************/
@@ -242,32 +282,56 @@ public final class MarketingWidgets
for(QRecord r : MarketingStore.query(MarketingContent.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, ContentStatus.IN_REVIEW.getId())).withLimit(25)))
{
rows.add(row("item", link(MarketingContent.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Waiting for review (revision " + r.getValueInteger("revisionNumber") + ")",
"since", String.valueOf(r.getValue("modifyDate"))));
"since", when(r.getValue("modifyDate"))));
}
for(QRecord r : MarketingStore.query(MarketingExecution.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
List.of(ExecutionStatus.BLOCKED.getId(), ExecutionStatus.NEEDS_RECONCILIATION.getId(), ExecutionStatus.FAILED.getId()))).withLimit(25)))
{
rows.add(row("item", link(MarketingExecution.TABLE_NAME, r.getValue("id"), "Execution #" + r.getValue("id")),
"what", ExecutionStatus.getById(r.getValueInteger("status")).getLabel() + ": " + StringUtils.safeTruncate(r.getValueString("statusDetail"), 160, "..."),
"since", String.valueOf(r.getValue("modifyDate"))));
"since", when(r.getValue("modifyDate"))));
}
for(QRecord r : MarketingStore.query(MarketingFinding.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("status", QCriteriaOperator.EQUALS, FindingStatus.OPEN.getId()),
new QFilterCriteria("severity", QCriteriaOperator.EQUALS, FindingSeverity.BLOCKER.getId())).withLimit(25)))
{
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", String.valueOf(r.getValue("createDate"))));
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", when(r.getValue("createDate"))));
}
for(QRecord r : MarketingStore.query(MarketingAgentRun.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.FAILED.getId()),
new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, Instant.now().minus(7, ChronoUnit.DAYS))).withLimit(10)))
{
rows.add(row("item", link(MarketingAgentRun.TABLE_NAME, r.getValue("id"), "Agent run #" + r.getValue("id")),
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", String.valueOf(r.getValue("createDate"))));
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
}
///////////////////////////////////////////////////////////////////////////////////
// a channel that cannot send: said in business words (the provider detail is in //
// the admin-only Configuration Status widget) //
///////////////////////////////////////////////////////////////////////////////////
for(QRecord r : MarketingStore.query(MarketingChannel.TABLE_NAME, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")).withLimit(50)))
{
if(StringUtils.hasContent(r.getValueString("transportName")) && !canSend(r.getValueString("transportName")))
{
rows.add(row("item", link(MarketingChannel.TABLE_NAME, r.getValue("id"), r.getValueString("name")), "what", "Sends on " + r.getValueString("name") + " are paused - ask an admin",
"since", ""));
}
}
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
"Nothing needs attention.")));
}
/*******************************************************************************
** Can the named transport send (registered, and operational or test-only)?
*******************************************************************************/
private static boolean canSend(String transportName) throws QException
{
MarketingTransport transport = MarketingRuntime.current().getTransport(transportName);
return (transport != null && transport.getStatus().isUsable());
}
}
@@ -320,6 +384,17 @@ public final class MarketingWidgets
*******************************************************************************/
public static class AgentActivity extends Base
{
/*******************************************************************************
** System content: marketing.admin.read.
*******************************************************************************/
@Override
String requiredPermission()
{
return (MarketingPermissions.ADMIN_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -361,6 +436,17 @@ public final class MarketingWidgets
*******************************************************************************/
public static class Configuration extends Base
{
/*******************************************************************************
** System content: marketing.admin.read.
*******************************************************************************/
@Override
String requiredPermission()
{
return (MarketingPermissions.ADMIN_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -438,9 +524,25 @@ public final class MarketingWidgets
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.ACTIVE.getId())));
String approval = active.isEmpty() ? "Not approved."
: "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " at " + active.get(0).getValue("approvedAt")
String approval;
if(!active.isEmpty())
{
approval = "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " on " + when(active.get(0).getValue("approvedAt"))
+ (content.getContentHash() != null && content.getContentHash().equals(active.get(0).getValueString("contentHash")) ? " (matches this revision)." : " (does NOT match this revision).");
}
else
{
//////////////////////////////////////////////////////////////////////
// an approval an execution used up is not "not approved": say so //
//////////////////////////////////////////////////////////////////////
List<QRecord> consumed = MarketingStore.query(MarketingApproval.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.CONSUMED.getId()),
new QFilterCriteria("contentHash", QCriteriaOperator.EQUALS, content.getContentHash())));
approval = consumed.isEmpty() ? "Not approved."
: "Approved by " + escape(consumed.get(0).getValueString("approvedByUserId")) + " on " + when(consumed.get(0).getValue("approvedAt"))
+ ", and used by an execution. Approve again to send this revision again.";
}
StringBuilder html = new StringBuilder("<div style=\"max-width:100%;overflow-wrap:anywhere\">");
html.append("<p><strong>").append(escape(content.getTitle())).append("</strong></p>");
@@ -450,7 +552,7 @@ public final class MarketingWidgets
item(html, "Call to action", content.getCallToAction());
item(html, "Destination", content.getDestinationUrl());
item(html, "Recipients", StringUtils.hasContent(content.getRecipientScope()) ? content.getRecipientScope() : "public (no recipient list)");
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : content.getScheduledFor().toString());
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : when(content.getScheduledFor()));
item(html, "Revision", content.getRevisionNumber() + " by " + label(AuthorType.getById(content.getAuthorType())));
item(html, "Content hash", content.getContentHash() == null ? "" : content.getContentHash().substring(0, 12));
item(html, "Status", label(ContentStatus.getById(content.getStatus())));
File diff suppressed because it is too large. Load diff
@@ -17,11 +17,15 @@
package com.kingsrook.qbits.marketing;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
import com.kingsrook.qbits.marketing.approval.ApprovalService;
@@ -45,6 +49,8 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
@@ -281,11 +287,10 @@ class AgentAndSecurityTest extends BaseMarketingTest
/*******************************************************************************
** Runs stop at their model-call limit; a cancelled run stops; the
** concurrency limit refuses extra runs.
** Runs stop at their model-call limit; a cancelled run stops.
*******************************************************************************/
@Test
void testLimitsCancelAndConcurrency() throws QException
void testLimitsAndCancel() throws QException
{
config.withMaxModelCallsPerRun(2);
try
@@ -316,25 +321,112 @@ class AgentAndSecurityTest extends BaseMarketingTest
{
throw (new RuntimeException(e));
}
return (new com.kingsrook.qbits.assistant.provider.ModelResponse().withParts(new java.util.ArrayList<>(List.of(
com.kingsrook.qbits.assistant.provider.ModelPart.toolUse("t1", MarketingTools.LIST_CHANNELS, Map.of())))).withStopReason("tool_use"));
return (new com.kingsrook.qbits.assistant.spi.ModelResponse().withParts(new java.util.ArrayList<>(List.of(
com.kingsrook.qbits.assistant.spi.ModelPart.toolUse("t1", MarketingTools.LIST_CHANNELS, Map.of())))).withStopReason("tool_use"));
});
MarketingAgentRun cancelled = new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(AgentRunStatus.getById(cancelled.getStatus())).isEqualTo(AgentRunStatus.CANCELLED);
}
MarketingRuntime runtime = MarketingRuntime.current();
assertThat(runtime.tryAcquireAgentPermit()).isTrue();
assertThat(runtime.tryAcquireAgentPermit()).isTrue();
try
/*******************************************************************************
** The concurrency limit is counted in the database: a second service
** instance (another replica on the same backend) is refused while the
** first one's run is RUNNING.
*******************************************************************************/
@Test
void testConcurrencyHoldsAcrossReplicas() throws QException
{
config.withMaxConcurrentAgentRuns(1);
CapturedContext context = QContext.capture();
List<MarketingAgentRun> refused = new ArrayList<>();
model.thenRespond(request ->
{
MarketingAgentRun busy = new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(busy.getErrorMessage()).contains("Too many agent runs");
}
finally
{
runtime.releaseAgentPermit();
runtime.releaseAgentPermit();
}
try
{
QContext.init(context);
refused.add(new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null));
}
catch(QException e)
{
throw (new RuntimeException(e));
}
return (new ModelResponse().withParts(new ArrayList<>(List.of(ModelPart.text("Done.")))).withStopReason(ModelResponse.STOP_END_TURN));
});
MarketingAgentRun first = new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(AgentRunStatus.getById(first.getStatus())).isEqualTo(AgentRunStatus.SUCCEEDED);
assertThat(first.getHeartbeatAt()).isNotNull();
assertThat(refused).hasSize(1);
assertThat(AgentRunStatus.getById(refused.get(0).getStatus())).isEqualTo(AgentRunStatus.FAILED);
assertThat(refused.get(0).getErrorMessage()).contains("Too many agent runs").contains("limit 1");
/////////////////////////////////////////////////////////////
// the first run finished, so the next one is let through //
/////////////////////////////////////////////////////////////
model.thenText("Again.");
assertThat(AgentRunStatus.getById(new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null).getStatus())).isEqualTo(AgentRunStatus.SUCCEEDED);
}
/*******************************************************************************
** A RUNNING run whose heartbeat is older than maxRunSeconds plus the grace
** is failed with a plain reason (and no longer counts); a fresh one stays.
*******************************************************************************/
@Test
void testStaleRunningRunIsReaped() throws QException
{
config.withMaxConcurrentAgentRuns(1);
Instant stale = Instant.now().minusSeconds(config.getMaxRunSeconds() + MarketingAgentService.STALE_GRACE_SECONDS + 30);
Integer staleId = MarketingStore.insertInternal(new MarketingAgentRun().withTask(AgentTask.ANALYZE_RESULTS.getId()).withStatus(AgentRunStatus.RUNNING.getId())
.withStartedAt(stale).withHeartbeatAt(stale)).getValueInteger("id");
model.thenText("Ran.");
MarketingAgentRun run = new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(AgentRunStatus.getById(run.getStatus())).isEqualTo(AgentRunStatus.SUCCEEDED);
QRecord reaped = MarketingStore.get(MarketingAgentRun.TABLE_NAME, staleId);
assertThat(reaped.getValueInteger("status")).isEqualTo(AgentRunStatus.FAILED.getId());
assertThat(reaped.getValueString("errorMessage")).isEqualTo("Stopped responding; the server may have restarted.");
///////////////////////////////////////////////////////////////////
// a RUNNING run with a recent heartbeat is alive and counts //
///////////////////////////////////////////////////////////////////
MarketingStore.insertInternal(new MarketingAgentRun().withTask(AgentTask.ANALYZE_RESULTS.getId()).withStatus(AgentRunStatus.RUNNING.getId())
.withStartedAt(stale).withHeartbeatAt(Instant.now()));
MarketingAgentRun refused = new MarketingAgentService().run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(refused.getErrorMessage()).contains("Too many agent runs");
}
/*******************************************************************************
** At the per-minute model-call limit a run waits for the window to clear
** rather than failing; the wait is bounded by maxRunSeconds.
*******************************************************************************/
@Test
void testModelCallRateWaitsInsteadOfFailing() throws QException
{
config.withMaxModelCallsPerMinute(1);
MarketingAgentService service = new MarketingAgentService().withModelCallWindow(Duration.ofMillis(1500), Duration.ofMillis(50));
model.thenText("One.").thenText("Two.");
assertThat(AgentRunStatus.getById(service.run(AgentTask.ANALYZE_RESULTS, campaignId, null, null).getStatus())).isEqualTo(AgentRunStatus.SUCCEEDED);
long start = System.currentTimeMillis();
MarketingAgentRun second = service.run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(AgentRunStatus.getById(second.getStatus())).isEqualTo(AgentRunStatus.SUCCEEDED);
assertThat(System.currentTimeMillis() - start).isGreaterThanOrEqualTo(500);
///////////////////////////////////////////////////////////////////
// a wait longer than the run's time ends the run, not the JVM //
///////////////////////////////////////////////////////////////////
config.withMaxRunSeconds(1);
MarketingAgentService slow = new MarketingAgentService().withModelCallWindow(Duration.ofSeconds(60), Duration.ofMillis(50));
MarketingAgentRun timedOut = slow.run(AgentTask.ANALYZE_RESULTS, campaignId, null, null);
assertThat(AgentRunStatus.getById(timedOut.getStatus())).isEqualTo(AgentRunStatus.FAILED);
assertThat(timedOut.getErrorMessage()).contains("exceeded 1 seconds").contains("rate limit");
}
}
@@ -0,0 +1,124 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.time.LocalDate;
import java.util.List;
import com.kingsrook.qbits.marketing.model.MarketingChannel;
import com.kingsrook.qbits.marketing.model.MarketingFinding;
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingType;
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** A channel's rulesVerifiedDate goes stale like an evidence source does:
** Check Readiness on a campaign warns when content uses a channel whose rules
** were last read more than staleChannelRulesDays ago, and points at the
** channel's rulesSourceUrl. The window is configuration (default 90).
*******************************************************************************/
class ChannelRulesStalenessTest extends BaseMarketingTest
{
/*******************************************************************************
** Open channel-rule findings for the campaign.
*******************************************************************************/
private List<QRecord> ruleFindings(Integer campaignId) throws QException
{
return (MarketingStore.queryBy(MarketingFinding.TABLE_NAME, "campaignId", campaignId).stream()
.filter(f -> FindingType.CHANNEL_RULE.getId().equals(f.getValueInteger("findingType")) && FindingStatus.OPEN.getId().equals(f.getValueInteger("status"))).toList());
}
/*******************************************************************************
** Fresh rules: no finding. Rules last read 91 days ago: a warning that names
** the date and the source URL. Re-reading them (a new date) clears it.
*******************************************************************************/
@Test
void staleChannelRulesRaiseAWarningAndRereadingClearsIt() throws QException
{
Integer campaignId = campaign(product("stale-rules"));
Integer channelId = channel("Forum", ChannelType.COMMUNITY, null, AiContentPolicy.ALLOWED_WITH_DISCLOSURE, false);
content(campaignId, channelId, "Body");
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesSourceUrl", "https://forum.example/rules"));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).as("rules verified today").isEmpty();
LocalDate old = LocalDate.now().minusDays(91);
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", old));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f ->
{
assertThat(f.getValueString("title")).contains("Channel rules may be out of date").contains("Forum");
assertThat(f.getValueString("detail")).contains(old.toString()).contains("90 days");
assertThat(f.getValueString("recommendation")).contains("https://forum.example/rules");
assertThat(f.getValueInteger("severity")).isEqualTo(FindingSeverity.WARNING.getId());
});
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", LocalDate.now().minusDays(89)));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).as("89 days is still current").isEmpty();
}
/*******************************************************************************
** The window is configuration, not a constant.
*******************************************************************************/
@Test
void windowIsConfigurable() throws QException
{
config.withStaleChannelRulesDays(7);
Integer campaignId = campaign(product("window"));
Integer channelId = channel("Blog", ChannelType.BLOG, null, AiContentPolicy.ALLOWED, false);
content(campaignId, channelId, "Body");
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", LocalDate.now().minusDays(10)));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f -> assertThat(f.getValueString("detail")).contains("7 days"));
}
/*******************************************************************************
** A channel with no rule date or an Unknown AI policy keeps the existing
** "not verified" warning (not the stale one), and it names the source URL.
*******************************************************************************/
@Test
void unverifiedChannelKeepsItsOwnWarning() throws QException
{
Integer campaignId = campaign(product("unverified"));
Integer channelId = channel("Unknown place", ChannelType.COMMUNITY, null, AiContentPolicy.UNKNOWN, false);
content(campaignId, channelId, "Body");
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesSourceUrl", "https://place.example/rules"));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f ->
{
assertThat(f.getValueString("title")).contains("Channel rules not verified");
assertThat(f.getValueString("recommendation")).contains("https://place.example/rules");
});
}
}
@@ -29,13 +29,16 @@ import com.kingsrook.qbits.marketing.model.MarketingContent;
import com.kingsrook.qbits.marketing.model.MarketingExecution;
import com.kingsrook.qbits.marketing.model.MarketingFinding;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingType;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
@@ -49,8 +52,11 @@ import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolInput;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
@@ -64,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -112,11 +119,16 @@ class ComponentsTest extends BaseMarketingTest
TableData config = (TableData) render(MarketingWidgets.CONFIGURATION, null).getWidgetData();
assertThat(config.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("status"))).isEqualTo("Not configured"))
.anySatisfy(r -> assertThat(String.valueOf(r.get("status"))).startsWith("Test only"));
assertThat(config.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("integration"))).contains("Outcome importer").contains("(none)"));
TableData campaigns = (TableData) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData();
assertThat(campaigns.getRows()).hasSize(4);
assertThat(campaigns.getColumns()).hasSizeLessThanOrEqualTo(3);
assertThat(((TableData) render(MarketingWidgets.AGENT_ACTIVITY, null).getWidgetData()).getNoRowsFoundHTML()).contains("not run yet");
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
//////////////////////////////////////////////////////////////////////////////////////////
// the starter email channel's transport is configured but not verified: sends paused //
//////////////////////////////////////////////////////////////////////////////////////////
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows())
.singleElement().satisfies(r -> assertThat(String.valueOf(r.get("what"))).endsWith("are paused - ask an admin"));
TableData results = (TableData) render(MarketingWidgets.CAMPAIGN_RESULTS, campaignId).getWidgetData();
assertThat(results.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("result"))).startsWith("0 unique actors"));
@@ -126,8 +138,81 @@ class ComponentsTest extends BaseMarketingTest
RawHTML proposal = (RawHTML) render(MarketingWidgets.CONTENT_PROPOSAL, contentId).getWidgetData();
assertThat(proposal.getHtml()).contains("&lt;script&gt;").doesNotContain("<script>").contains("Not approved");
as(user("no-read", com.kingsrook.qbits.marketing.security.MarketingPermissions.APP_ACCESS));
assertThat(((RawHTML) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData()).getHtml()).contains("marketing.read");
}
/*******************************************************************************
** The business home lists only business widgets: no configuration status
** and no agent activity (token and model usage), which are admin-placeable.
*******************************************************************************/
@Test
void testHomeHasNoSystemWidgets()
{
List<String> home = qInstance.getApp("marketing").getWidgets();
assertThat(home).containsExactly(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS)
.doesNotContain(MarketingWidgets.CONFIGURATION, MarketingWidgets.AGENT_ACTIVITY);
assertThat(MarketingWidgets.ADMIN_WIDGETS).containsExactly(MarketingWidgets.AGENT_ACTIVITY, MarketingWidgets.CONFIGURATION);
assertThat(qInstance.getWidget(MarketingWidgets.CONFIGURATION)).isNotNull();
assertThat(qInstance.getWidget(MarketingWidgets.AGENT_ACTIVITY)).isNotNull();
}
/*******************************************************************************
** App access without marketing.read: every read-requiring widget is omitted
** (no permission) and never renders a "You need ..." shell. Admins still
** get the moved widgets; read-only users do not.
*******************************************************************************/
@Test
void testWidgetsAreOmittedWithoutRead() throws QException
{
List<String> readWidgets = List.of(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS, MarketingWidgets.CAMPAIGN_RESULTS, MarketingWidgets.CONTENT_PROPOSAL);
List<String> adminWidgets = MarketingWidgets.ADMIN_WIDGETS;
as(user("no-read", MarketingPermissions.APP_ACCESS));
for(String name : CollectionUtils.mergeLists(readWidgets, adminWidgets))
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
assertThatThrownBy(() -> render(name, null)).as(name).isInstanceOf(QPermissionDeniedException.class);
}
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
for(String name : readWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
}
for(String name : adminWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
}
as(admin());
for(String name : adminWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
assertThat(render(name, null).getWidgetData()).as(name).isInstanceOf(TableData.class);
}
}
/*******************************************************************************
** A channel that cannot send adds one business-worded row linking to the
** channel; nothing when every channel can send.
*******************************************************************************/
@Test
void testNeedsAttentionSaysWhenAChannelCannotSend() throws QException
{
testChannel();
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
Integer blocked = channel("Newsletter", ChannelType.TEST_SINK, "no-such-transport", AiContentPolicy.ALLOWED, true);
List<Map<String, Object>> rows = ((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows();
assertThat(rows).hasSize(1);
assertThat(String.valueOf(rows.get(0).get("what"))).isEqualTo("Sends on Newsletter are paused - ask an admin");
assertThat(String.valueOf(rows.get(0).get("item"))).contains("marketing_channel").contains(String.valueOf(blocked)).contains("Newsletter");
assertThat(String.valueOf(rows.get(0))).doesNotContain("transport").doesNotContain("Not configured");
}
@@ -21,8 +21,7 @@ import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
import com.kingsrook.qbits.marketing.measurement.MeasurementReport;
@@ -254,6 +253,14 @@ class EndToEndFlowTest extends BaseMarketingTest
assertThat(deliveries.get(0).getValueString("body")).as("exactly the approved text").isEqualTo(body);
assertThat(reload(MarketingApproval.TABLE_NAME, approval.getValueInteger("id")).getValueInteger("status")).isEqualTo(ApprovalStatus.CONSUMED.getId());
assertThat(reload(MarketingContent.TABLE_NAME, contentId).getValueInteger("status")).isEqualTo(ContentStatus.ACCEPTED.getId());
com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput proposalInput = new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()
.withWidgetMetaData(com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getWidget(com.kingsrook.qbits.marketing.widgets.MarketingWidgets.CONTENT_PROPOSAL));
proposalInput.setQueryParams(new java.util.HashMap<>(java.util.Map.of("id", String.valueOf(contentId))));
com.kingsrook.qqq.backend.core.context.QContext.getQSession().setValue(com.kingsrook.qqq.backend.core.model.session.QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago");
String proposal =((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) new com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction()
.execute(proposalInput).getWidgetData()).getHtml();
assertThat(proposal).as("a used-up approval is not shown as not approved").contains("used by an execution").doesNotContain("Not approved")
.containsPattern("[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4} [0-9]{1,2}:[0-9]{2} [AP]M C[DS]T");
///////////////////////////////////////////////////////////////////
// 7. recorded outcomes: the provider acceptance (test data) and //
@@ -0,0 +1,125 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.actions.help.HelpTarget;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundle;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleLayer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The help bundle (help/qbit-marketing.yaml) covers everything the qbit
** produces, and every entry is sound. Core's HelpBundleAssertions checks
** that the app, every marketing_* table, every non-trivial field and every
** process has help, that every key and {{...}} reference resolves and that
** summaries are short plain text. Core does not require widget or
** possible-value-source coverage, so those are checked here too. QQQ's
** generated bulk table processes are core's to document and are not owned.
*******************************************************************************/
class HelpBundleCoverageTest extends BaseMarketingTest
{
/*******************************************************************************
** The producer registers exactly this qbit's bundle, at the QBIT layer.
*******************************************************************************/
@Test
void testRegisteredBundle()
{
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-marketing");
assertThat(bundle.getLayer()).isEqualTo(HelpBundleLayer.QBIT);
assertThat(bundle.getVersion()).isEqualTo(MarketingQBitProducer.VERSION);
});
}
/*******************************************************************************
** Core coverage gate over everything the qbit owns.
*******************************************************************************/
@Test
void testCoreCoverageGate()
{
HelpBundleAssertions.forInstance(qInstance)
.bundles(MarketingQBitProducer.HELP_BUNDLE)
.owning(HelpBundleCoverageTest::ownedByMarketing)
.assertComplete();
}
/*******************************************************************************
** The qbit owns its app, marketing_* objects and the processes it produces.
** QQQ's generated bulk table processes (no source qbit) are core's to document.
*******************************************************************************/
private static boolean ownedByMarketing(HelpTarget target)
{
if(target.owner() instanceof QProcessMetaData process && process.getSourceQBitName() == null)
{
return (false);
}
return (target.name().equals("marketing") || target.name().startsWith("marketing_") || target.name().startsWith("Marketing"));
}
/*******************************************************************************
** Every widget and possible-value source has an entry, and every entry has
** a summary and content.
*******************************************************************************/
@Test
void testWidgetsSourcesAndContent()
{
Map<HelpBundles.EntryKey, HelpBundles.WinningEntry> entries = HelpBundles.of(qInstance).resolveWinningEntries();
List<String> problems = new ArrayList<>();
TreeSet<String> expected = new TreeSet<>();
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("widget:" + n + ";slot:label"));
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("possibleValueSource:" + n + ";slot:help"));
for(String key : expected)
{
if(!entries.containsKey(new HelpBundles.EntryKey(key, "ALL_SCREENS")))
{
problems.add("missing entry: " + key);
}
}
HelpBundle bundle = HelpBundles.of(qInstance).getBundles().getFirst();
for(HelpBundleEntry entry : bundle.getEntries())
{
if(entry.getSummary() == null || entry.getSummary().isBlank())
{
problems.add(entry.getKey() + ": no summary");
}
if(entry.getContent() == null || entry.getContent().isBlank())
{
problems.add(entry.getKey() + ": no content");
}
}
assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty();
}
}
@@ -21,8 +21,10 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.marketing.agent.KernelToolRegistration;
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolRegistry;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
@@ -52,7 +54,7 @@ class MarketingQBitProducerTest extends BaseMarketingTest
assertThat(qInstance.getQBits().values().iterator().next().getArtifactId()).isEqualTo("qbit-marketing");
Set<String> tables = qInstance.getTables().keySet();
assertThat(tables).hasSize(18).allMatch(t -> t.matches("^marketing_[a-z][a-z0-9_]*$"));
assertThat(tables).hasSize(19).allMatch(t -> t.matches("^marketing_[a-z][a-z0-9_]*$"));
for(QTableMetaData table : qInstance.getTables().values())
{
assertThat(table.getPermissionRules().getPermissionBaseName()).startsWith("marketing");
@@ -111,12 +113,16 @@ class MarketingQBitProducerTest extends BaseMarketingTest
/*******************************************************************************
** On QQQ 4.1.0-RC.4 the kernel hook is absent; the qbit still produces.
** RC.6 registers the read tools, excludes writes and tolerates repeat registration.
*******************************************************************************/
@Test
void testKernelHookAbsentOnRc4()
void testKernelHookAvailableOnRc6()
{
assertThat(KernelToolRegistration.isAvailable()).isFalse();
assertThat(KernelToolRegistration.isAvailable()).isTrue();
assertThat(AiToolRegistry.of(qInstance).getTools()).containsOnlyKeys(
MarketingTools.GET_CAMPAIGN_BRIEF, MarketingTools.LIST_PRODUCT_CLAIMS, MarketingTools.LIST_CAMPAIGN_CONTENT,
MarketingTools.GET_CAMPAIGN_RESULTS, MarketingTools.LIST_CHANNELS, MarketingTools.LIST_FINDINGS);
assertThat(KernelToolRegistration.register(qInstance, MarketingTools.all())).isZero();
assertThat(KernelToolRegistration.register(qInstance, List.of())).isZero();
}
@@ -0,0 +1,232 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpTextRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every marketing_* table:
** - runtime code (not just its model, metadata, home widgets or the starter
** data) reads or writes it, so no table exists only on paper;
** - it is either seeded by the starter data ("Load Verified Starter Data") or
** listed here with why it starts empty;
** - it has bundle help that says how its rows get created, naming the starter
** data action where that is one of the ways.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class MarketingTablesGuardrailTest extends BaseMarketingTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "marketing");
/*******************************************************************************
** Written by MarketingStarterData (checked against its source below).
*******************************************************************************/
static final List<String> SEEDED_BY_STARTER_DATA = List.of("marketing_product", "marketing_evidence_source", "marketing_claim", "marketing_audience",
"marketing_campaign", "marketing_metric", "marketing_channel", "marketing_policy");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("marketing_content", "user content: a person or the agent drafts it (starter data never writes publications)"),
Map.entry("marketing_content_revision", "runtime: content is created or changed"),
Map.entry("marketing_approval", "runtime: a person approves content"),
Map.entry("marketing_execution", "runtime: Schedule on approved content"),
Map.entry("marketing_execution_attempt", "runtime: an execution is tried"),
Map.entry("marketing_test_delivery", "runtime: the test transport accepts an execution"),
Map.entry("marketing_agent_run", "runtime: someone starts an agent action"),
Map.entry("marketing_agent_step", "runtime: an agent run makes a call"),
Map.entry("marketing_finding", "runtime: Check Readiness, the agent or a person"),
Map.entry("marketing_outcome_event", "runtime: an importer, the API, Update Results from CRM or a person"),
Map.entry("marketing_metric_daily", "runtime: rolled up from outcome events"));
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> entityByTable = entityByTable();
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("seed")) && !p.startsWith(MAIN.resolve("widgets")))
.map(MarketingTablesGuardrailTest::read).toList();
}
for(String table : marketingTables())
{
String entity = entityByTable.get(table);
assertThat(entity).as("entity class for " + table).isNotNull();
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
String starter = read(MAIN.resolve("seed").resolve("MarketingStarterData.java"));
Map<String, String> entityByTable = entityByTable();
for(String table : marketingTables())
{
boolean seeded = SEEDED_BY_STARTER_DATA.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the starter data or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
assertThat(starter.contains(entityByTable.get(table) + ".TABLE_NAME")).as(table + " seeded iff MarketingStarterData writes it").isEqualTo(seeded);
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
assertThat(qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getTableName()).isEqualTo("marketing_product");
}
/*******************************************************************************
** Every table and the app have help that says how rows get created, and the
** seeded tables name the starter-data action.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
String action = qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getLabel();
for(String table : marketingTables())
{
String content = help("table:" + table + ";slot:help");
assertThat(content).as(table + " table help").isNotBlank();
assertThat(content).as(table + " help says how rows get created").contains("How rows get created");
assertThat(content).as(table + " help has no unresolved reference").doesNotContain("{{");
if(SEEDED_BY_STARTER_DATA.contains(table))
{
assertThat(content).as(table + " help names the starter-data action").contains(action);
}
}
assertThat(help("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help")).isNotBlank();
assertThat(help("table:marketing_content;slot:help")).as("enum references become the values").contains("Draft");
}
/*******************************************************************************
** The bundle is registered at the QBIT layer, so a host bundle for the same
** key wins and the host's own help is kept.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden()
{
HelpBundles.register(qInstance, "help/test-host-override.yaml");
assertThat(help("table:marketing_claim;slot:help")).isEqualTo("host text");
assertThat(help("table:marketing_product;slot:help")).contains("How rows get created");
}
/*******************************************************************************
** The winning bundle text for a key (ALL_SCREENS role), rendered by core
** against the instance the way the frontend sees it.
*******************************************************************************/
private String help(String key)
{
HelpBundles.WinningEntry winner = HelpBundles.of(qInstance).resolveWinningEntries().get(new HelpBundles.EntryKey(key, "ALL_SCREENS"));
if(winner == null)
{
return (null);
}
HelpTextRenderer renderer = new HelpTextRenderer(qInstance);
String summary = winner.entry().getSummary() == null ? "" : renderer.render(winner.entry().getSummary().strip());
return ((summary + "\n\n" + renderer.render(winner.entry().getContent().strip())).strip());
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> marketingTables()
{
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("marketing_")).sorted().toList();
assertThat(tables).hasSize(19);
return (tables);
}
/*******************************************************************************
** model/MarketingX.java declares TABLE_NAME = "marketing_x".
*******************************************************************************/
private static Map<String, String> entityByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
{
for(Path file : files.filter(f -> f.toString().endsWith(".java")).toList())
{
Matcher m = p.matcher(read(file));
if(m.find())
{
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,483 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import com.kingsrook.qbits.marketing.measurement.ImportedOutcome;
import com.kingsrook.qbits.marketing.measurement.MarketingOutcomeImporter;
import com.kingsrook.qbits.marketing.measurement.MeasurementReport;
import com.kingsrook.qbits.marketing.measurement.MeasurementRollupService;
import com.kingsrook.qbits.marketing.measurement.OutcomeImportService;
import com.kingsrook.qbits.marketing.model.MarketingMetric;
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.model.enums.CountMode;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
import com.kingsrook.qbits.marketing.model.enums.InstrumentationStatus;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.widgets.MarketingWidgets;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The persisted daily rollup of outcome events (marketing_metric_daily): it
** matches the raw computation across inserts, edits, deletes and test data;
** Rebuild Results is idempotent; the campaign-wide unique count is not summed
** from days; and the importer SPI stores outcomes through the normal dedupe.
*******************************************************************************/
class MeasurementRollupTest extends BaseMarketingTest
{
private static final Instant DAY_ONE = Instant.parse("2026-10-01T10:00:00Z");
private static final Instant DAY_TWO = Instant.parse("2026-10-02T23:59:59Z");
private final FakeImporter importer = new FakeImporter("fake-analytics");
private Integer campaignId;
/*******************************************************************************
** An importer that returns what the test queued, and remembers its "since".
*******************************************************************************/
static class FakeImporter implements MarketingOutcomeImporter
{
private final String name;
final List<ImportedOutcome> outcomes = new ArrayList<>();
final List<Instant> sinces = new ArrayList<>();
boolean fail = false;
/*******************************************************************************
**
*******************************************************************************/
FakeImporter(String name)
{
this.name = name;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<ImportedOutcome> fetch(QRecord campaign, Instant since) throws QException
{
sinces.add(since);
if(fail)
{
throw (new QUserFacingException("the analytics export is unreachable"));
}
return (new ArrayList<>(outcomes));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeConfig(MarketingQBitConfig config)
{
config.withOutcomeImporter(importer);
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
campaignId = campaign(product("p1"));
}
/*******************************************************************************
**
*******************************************************************************/
private Integer event(FunnelStage stage, String type, String externalId, String actor, Integer quantity, Instant occurredAt, boolean test) throws QException
{
return (MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(stage.getId()).withEventType(type).withSource("site")
.withExternalEventId(externalId).withActorKey(actor).withQuantity(quantity).withOccurredAt(occurredAt).withIsTestData(test)).getValueInteger("id"));
}
/*******************************************************************************
**
*******************************************************************************/
private void metric(FunnelStage stage, String eventType, CountMode mode, InstrumentationStatus status) throws QException
{
MarketingStore.insert(new MarketingMetric().withCampaignId(campaignId).withStage(stage.getId()).withName(eventType).withEventType(eventType)
.withCountMode(mode.getId()).withInstrumentationStatus(status.getId()));
}
/*******************************************************************************
** The raw computation, straight from the events: "day|stage|test|type" to
** "raw|uniqueActors|withoutActor".
*******************************************************************************/
private Map<String, String> rawComputation() throws QException
{
Map<String, int[]> counts = new TreeMap<>();
Map<String, Set<String>> actors = new TreeMap<>();
for(QRecord event : MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId))
{
LocalDate day = event.getValueInstant("occurredAt").atZone(ZoneOffset.UTC).toLocalDate();
String key = day + "|" + event.getValueInteger("stage") + "|" + Boolean.TRUE.equals(event.getValueBoolean("isTestData")) + "|" + event.getValueString("eventType");
int[] sum = counts.computeIfAbsent(key, k -> new int[2]);
sum[0] += event.getValueInteger("quantity") == null ? 1 : event.getValueInteger("quantity");
String actor = event.getValueString("actorKey");
if(actor == null || actor.isBlank())
{
sum[1]++;
}
else
{
actors.computeIfAbsent(key, k -> new HashSet<>()).add(actor);
}
}
Map<String, String> expected = new TreeMap<>();
counts.forEach((key, sum) -> expected.put(key, sum[0] + "|" + actors.getOrDefault(key, Set.of()).size() + "|" + sum[1]));
return (expected);
}
/*******************************************************************************
** What the rollup table holds, in the same shape.
*******************************************************************************/
private Map<String, String> rollupRows() throws QException
{
Map<String, String> rows = new TreeMap<>();
for(QRecord row : MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId))
{
rows.put(row.getValueLocalDate("dayUtc") + "|" + row.getValueInteger("funnelStage") + "|" + Boolean.TRUE.equals(row.getValueBoolean("isTestData")) + "|" + row.getValueString("eventType"),
row.getValueInteger("rawCount") + "|" + row.getValueInteger("uniqueActors") + "|" + row.getValueInteger("eventsWithoutActor"));
}
return (rows);
}
/*******************************************************************************
**
*******************************************************************************/
private void assertRollupMatchesRaw() throws QException
{
assertThat(rollupRows()).isEqualTo(rawComputation());
}
/*******************************************************************************
** The rollup follows every insert, edit (quantity, actor, type, day, test
** flag) and delete, including the last event of a day and test-data rows.
*******************************************************************************/
@Test
void testRollupMatchesRawAcrossInsertsEditsAndDeletes() throws QException
{
Integer v1 = event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
Integer v2 = event(FunnelStage.AWARENESS, "page_view", "v2", "x", 3, DAY_ONE, false);
Integer v3 = event(FunnelStage.AWARENESS, "page_view", "v3", null, null, DAY_ONE, false);
Integer v4 = event(FunnelStage.AWARENESS, "page_view", "v4", "y", 2, DAY_TWO, false);
Integer r1 = event(FunnelStage.ACTIVATION, "first_run", "r1", "x", null, DAY_ONE, false);
Integer t1 = event(FunnelStage.AWARENESS, "page_view", "t1", "z", null, DAY_ONE, true);
assertRollupMatchesRaw();
assertThat(rollupRows()).containsEntry("2026-10-01|" + FunnelStage.AWARENESS.getId() + "|false|page_view", "5|1|1")
.containsEntry("2026-10-01|" + FunnelStage.AWARENESS.getId() + "|true|page_view", "1|1|0")
.containsEntry("2026-10-02|" + FunnelStage.AWARENESS.getId() + "|false|page_view", "2|1|0")
.hasSize(4);
/////////////////////////////////////////////////////////////////
// edits: quantity, actor, type, day, test flag //
/////////////////////////////////////////////////////////////////
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v2).withValue("quantity", 10));
assertRollupMatchesRaw();
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v3).withValue("actorKey", "w"));
assertRollupMatchesRaw();
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", r1).withValue("eventType", "second_run"));
assertRollupMatchesRaw();
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v1).withValue("occurredAt", DAY_TWO));
assertRollupMatchesRaw();
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", t1).withValue("isTestData", false));
assertRollupMatchesRaw();
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v4).withValue("isTestData", true));
assertRollupMatchesRaw();
///////////////////////////////////////////////////////////////////////
// an event cannot lose its time (the rollup is by day) //
///////////////////////////////////////////////////////////////////////
assertThatThrownBy(() -> MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v2).withValue("occurredAt", null)))
.hasMessageContaining("needs the time");
/////////////////////////////////////
// deletes, down to nothing //
/////////////////////////////////////
MarketingStore.delete(MarketingOutcomeEvent.TABLE_NAME, List.of(v2));
assertRollupMatchesRaw();
MarketingStore.delete(MarketingOutcomeEvent.TABLE_NAME, List.of(v1, v3, v4, r1, t1));
assertThat(rollupRows()).isEmpty();
assertRollupMatchesRaw();
}
/*******************************************************************************
** The rollup rows are system-written: a direct write is refused, and each
** row carries the product of its campaign (for the product record lock).
*******************************************************************************/
@Test
void testRollupIsSystemWrittenAndCarriesTheProduct() throws QException
{
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
QRecord row = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId).get(0);
assertThat(row.getValueInteger("productId")).isEqualTo(MarketingStore.get("marketing_campaign", campaignId).getValueInteger("productId"));
assertThat(row.getValueInstant("recomputedAt")).isNotNull();
assertThatThrownBy(() -> MarketingStore.insert(new MarketingMetricDaily().withCampaignId(campaignId).withDayUtc(LocalDate.of(2026, 10, 3))
.withFunnelStage(FunnelStage.AWARENESS.getId()).withEventType("x").withIsTestData(false).withRawCount(99)))
.hasMessageContaining("written only by Marketing");
assertThatThrownBy(() -> MarketingStore.update(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("rawCount", 99)))
.hasMessageContaining("written only by Marketing");
assertThatThrownBy(() -> MarketingStore.delete(MarketingMetricDaily.TABLE_NAME, List.of(row.getValue("id"))))
.hasMessageContaining("written only by Marketing");
}
/*******************************************************************************
** Rebuild Results repairs a damaged rollup (wrong numbers, missing rows, a
** stale row) from the raw events, and a second run changes nothing.
*******************************************************************************/
@Test
void testRebuildIsIdempotent() throws QException
{
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
event(FunnelStage.AWARENESS, "page_view", "v2", "y", 4, DAY_TWO, false);
event(FunnelStage.ACTIVATION, "first_run", "r1", "x", null, DAY_ONE, true);
Map<String, String> expected = rawComputation();
assertThat(rollupRows()).isEqualTo(expected);
///////////////////////////////////////////////////////////
// damage: a wrong number, a missing row, a stale row //
///////////////////////////////////////////////////////////
List<QRecord> rows = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
MarketingStore.updateInternal(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", rows.get(0).getValue("id")).withValue("rawCount", 999));
MarketingStore.deleteInternal(MarketingMetricDaily.TABLE_NAME, List.<Serializable>of(rows.get(1).getValue("id")));
MarketingStore.insertInternal(new MarketingMetricDaily().withCampaignId(campaignId).withDayUtc(LocalDate.of(2026, 9, 1)).withFunnelStage(FunnelStage.AWARENESS.getId())
.withEventType("ghost").withIsTestData(false).withRawCount(7).withUniqueActors(7).withEventsWithoutActor(0));
assertThat(rollupRows()).isNotEqualTo(expected);
String first = new MeasurementRollupService().rebuild(campaignId);
assertThat(first).isEqualTo("rebuilt 3 daily result row(s) over 3 day(s)");
assertThat(rollupRows()).isEqualTo(expected);
List<QRecord> afterFirst = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
String second = new MeasurementRollupService().rebuild(campaignId);
List<QRecord> afterSecond = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
assertThat(second).isEqualTo("rebuilt 3 daily result row(s) over 2 day(s)");
assertThat(rollupRows()).isEqualTo(expected);
assertThat(afterSecond).hasSameSizeAs(afterFirst);
for(int i = 0; i < afterFirst.size(); i++)
{
assertThat(afterSecond.get(i).getValue("id")).isEqualTo(afterFirst.get(i).getValue("id"));
assertThat(afterSecond.get(i).getValueInstant("recomputedAt")).as("an unchanged row is not rewritten").isEqualTo(afterFirst.get(i).getValueInstant("recomputedAt"));
}
}
/*******************************************************************************
** Rebuild Results is a campaign record action with standard summary lines,
** and needs the same permission as Update Results from CRM.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void testRebuildResultsAction() throws QException
{
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
MarketingStore.deleteInternal(MarketingMetricDaily.TABLE_NAME, List.<Serializable>of(MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId).get(0).getValue("id")));
assertThat(rollupRows()).isEmpty();
RunProcessOutput output = new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingActions.REBUILD_RESULTS)
.withCallback(QProcessCallbackFactory.forPrimaryKey("id", campaignId)).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
List<ProcessSummaryLine> lines = (List<ProcessSummaryLine>) (List<?>) output.getValues().get("processResults");
assertThat(lines).anySatisfy(l -> assertThat(l.getMessage()).isEqualTo("rebuilt 1 daily result row(s) over 1 day(s)"));
assertRollupMatchesRaw();
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
assertThatThrownBy(() -> new MeasurementRollupService().rebuild(campaignId)).hasMessageContaining(MarketingPermissions.PREPARE);
as(admin());
}
/*******************************************************************************
** The report reads the rollup and a backend COUNT_DISTINCT: an actor seen
** on two days is one unique actor for the campaign (not the sum of days),
** and test data and not-measured stay as they were.
*******************************************************************************/
@Test
void testReportUsesTheRollupAndCountsUniqueActorsAcrossDays() throws QException
{
metric(FunnelStage.AWARENESS, "page_view", CountMode.UNIQUE_ACTORS, InstrumentationStatus.PARTIAL);
metric(FunnelStage.ACTIVATION, "first_run", CountMode.RAW_EVENTS, InstrumentationStatus.INSTRUMENTED);
metric(FunnelStage.REPEAT_USE, "second_run", CountMode.UNIQUE_ACTORS, InstrumentationStatus.NOT_INSTRUMENTED);
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
event(FunnelStage.AWARENESS, "page_view", "v2", "x", null, DAY_TWO, false);
event(FunnelStage.AWARENESS, "page_view", "v3", "y", 2, DAY_TWO, false);
event(FunnelStage.AWARENESS, "page_view", "v4", null, null, DAY_ONE, false);
event(FunnelStage.AWARENESS, "page_view", "v5", " ", null, DAY_ONE, false);
event(FunnelStage.AWARENESS, "page_view", "tv", "tester", null, DAY_ONE, true);
event(FunnelStage.ACTIVATION, "first_run", "r1", "x", 5, DAY_ONE, false);
event(FunnelStage.AWARENESS, "star", "s1", "y", null, DAY_ONE, false);
MeasurementReport report = MeasurementReport.forCampaign(campaignId);
MeasurementReport.Row views = report.getRows().stream().filter(r -> r.eventType().equals("page_view")).findFirst().orElseThrow();
MeasurementReport.Row runs = report.getRows().stream().filter(r -> r.eventType().equals("first_run")).findFirst().orElseThrow();
MeasurementReport.Row again = report.getRows().stream().filter(r -> r.eventType().equals("second_run")).findFirst().orElseThrow();
assertThat(views.rawEvents()).isEqualTo(6);
assertThat(views.uniqueActors()).as("x on two days is one actor").isEqualTo(2);
assertThat(views.eventsWithoutActor()).isEqualTo(2);
assertThat(views.headline()).isEqualTo("2 unique actors");
assertThat(views.note()).contains("lower bound").contains("2 event(s) have no actor");
assertThat(runs.rawEvents()).isEqualTo(5);
assertThat(runs.headline()).isEqualTo("5 events");
assertThat(again.headline()).isEqualTo(MeasurementReport.NOT_MEASURED);
assertThat(report.getTestEvents()).isEqualTo(1);
assertThat(report.getLimitations()).anyMatch(l -> l.contains("1 test event(s)")).anyMatch(l -> l.contains("AWARENESS/star")).anyMatch(l -> l.contains("lower bound"));
assertThat(report.toMap()).containsEntry("realEventCount", 7).containsEntry("testEventCountExcluded", 1);
}
/*******************************************************************************
** An importer's outcomes go through the normal insert path: a repeat is
** dropped by (source, external id), the importer is told what it already
** delivered, and the daily rollup follows.
*******************************************************************************/
@Test
void testImporterRepeatIsDeduplicated() throws QException
{
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-1", "x", DAY_ONE, 4, false, null));
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-2", "y", DAY_TWO, null, false, "second day"));
importer.outcomes.add(new ImportedOutcome(FunnelStage.ENGAGEMENT, "signup", "ga-3", "x", DAY_TWO, 1, true, null));
importer.outcomes.add(new ImportedOutcome(null, "bad", "ga-4", null, null, null, null, null));
OutcomeImportService service = new OutcomeImportService();
assertThat(service.blocker()).isNull();
assertThat(service.importOutcomes(campaignId)).isEqualTo("fake-analytics: 3 imported, 0 already recorded, 1 invalid (skipped)");
assertThat(service.importOutcomes(campaignId)).isEqualTo("fake-analytics: 0 imported, 3 already recorded, 1 invalid (skipped)");
List<QRecord> events = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId);
assertThat(events).hasSize(3).allSatisfy(e -> assertThat(e.getValueString("source")).isEqualTo("fake-analytics"));
assertThat(events).anySatisfy(e -> assertThat(e.getValueInteger("quantity")).isEqualTo(4));
assertThat(importer.sinces).containsExactly(null, DAY_TWO);
assertRollupMatchesRaw();
assertThat(rollupRows()).hasSize(3);
}
/*******************************************************************************
** Import Outcomes is a campaign record action with standard lines; one
** failing importer is named and does not stop the others.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void testImportOutcomesActionAndFailingImporter() throws QException
{
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-1", "x", DAY_ONE, null, false, null));
RunProcessOutput output = new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingActions.IMPORT_OUTCOMES)
.withCallback(QProcessCallbackFactory.forPrimaryKey("id", campaignId)).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
List<ProcessSummaryLine> lines = (List<ProcessSummaryLine>) (List<?>) output.getValues().get("processResults");
assertThat(lines).anySatisfy(l -> assertThat(l.getMessage()).isEqualTo("fake-analytics: 1 imported, 0 already recorded"));
importer.fail = true;
output = new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingActions.IMPORT_OUTCOMES)
.withCallback(QProcessCallbackFactory.forPrimaryKey("id", campaignId)).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
lines = (List<ProcessSummaryLine>) (List<?>) output.getValues().get("processResults");
assertThat(lines).anySatisfy(l -> assertThat(l.getMessage()).contains("not done: fake-analytics: failed (the analytics export is unreachable)"));
assertThat(MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId)).hasSize(1);
}
/*******************************************************************************
** Registered importers are listed by the Configuration Status widget (and
** its absence says so); the names Marketing uses itself are reserved.
*******************************************************************************/
@Test
void testConfigurationStatusListsImportersAndNamesAreChecked() throws QException
{
TableData status = (TableData) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(qInstance.getWidget(MarketingWidgets.CONFIGURATION))).getWidgetData();
assertThat(status.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("integration"))).contains("Outcome importer").contains("fake-analytics"));
List<String> errors = new ArrayList<>();
new MarketingQBitConfig().withBackendName(BACKEND).withOutcomeImporter(new FakeImporter("crm")).withOutcomeImporter(new FakeImporter("dup"))
.withOutcomeImporter(new FakeImporter("dup")).validate(qInstance, errors);
assertThat(errors).anyMatch(e -> e.contains("[crm] is reserved")).anyMatch(e -> e.contains("duplicate outcome importer name [dup]"));
}
}
@@ -25,19 +25,34 @@ import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.marketing.approval.ApprovalService;
import com.kingsrook.qbits.marketing.measurement.ImportedOutcome;
import com.kingsrook.qbits.marketing.measurement.MeasurementReport;
import com.kingsrook.qbits.marketing.measurement.MeasurementRollupService;
import com.kingsrook.qbits.marketing.measurement.OutcomeImportService;
import com.kingsrook.qbits.marketing.execution.ExecutionService;
import com.kingsrook.qbits.marketing.model.MarketingAgentRun;
import com.kingsrook.qbits.marketing.model.MarketingApproval;
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
import com.kingsrook.qbits.marketing.model.MarketingContent;
import com.kingsrook.qbits.marketing.model.MarketingContentRevision;
import com.kingsrook.qbits.marketing.model.MarketingExecution;
import com.kingsrook.qbits.marketing.model.MarketingMetric;
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
import com.kingsrook.qbits.marketing.model.MarketingTestDelivery;
import com.kingsrook.qbits.marketing.model.enums.AgentRunStatus;
import com.kingsrook.qbits.marketing.model.enums.AgentTask;
import com.kingsrook.qbits.marketing.model.enums.ApprovalStatus;
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
import com.kingsrook.qbits.marketing.model.enums.CampaignStatus;
import com.kingsrook.qbits.marketing.model.enums.CountMode;
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
import com.kingsrook.qbits.marketing.model.enums.InstrumentationStatus;
import com.kingsrook.qbits.marketing.model.enums.ProductMaturity;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
@@ -91,13 +106,23 @@ class PersistenceRestartTest
** A fresh "process": a new instance and runtime over the database files.
*******************************************************************************/
private QInstance boot() throws Exception
{
return (boot(new MarketingQBitConfig()));
}
/*******************************************************************************
** A fresh "process" with the given config (its backend is set here).
*******************************************************************************/
private QInstance boot(MarketingQBitConfig config) throws Exception
{
QInstance qInstance = new QInstance();
qInstance.addBackend(new RDBMSBackendMetaData().withName("rdbms").withVendor("h2").withHostName("file").withDatabaseName(directory.resolve("marketing").toString())
.withUsername("sa").withPassword(""));
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
qInstance.setEnforcePermissionsInCoreActions(true);
new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig().withBackendName("rdbms")).produce(qInstance);
new MarketingQBitProducer().withQBitConfig(config.withBackendName("rdbms")).produce(qInstance);
new QInstanceValidator().validate(qInstance);
QContext.init(qInstance, BaseMarketingTest.admin());
return (qInstance);
@@ -116,10 +141,9 @@ class PersistenceRestartTest
/*******************************************************************************
**
** A new database directory with the shipped H2 DDL applied.
*******************************************************************************/
@Test
void testSurvivesRestart() throws Exception
private void createSchema() throws Exception
{
directory = Files.createDirectories(Path.of("target", "h2-restart", String.valueOf(System.nanoTime())).toAbsolutePath());
try(Connection connection = DriverManager.getConnection(jdbcUrl(), "sa", ""); Statement statement = connection.createStatement();
@@ -135,6 +159,30 @@ class PersistenceRestartTest
}
}
}
}
/*******************************************************************************
** Close the database files (the restart).
*******************************************************************************/
private void shutdown() throws Exception
{
try(Connection connection = DriverManager.getConnection(jdbcUrl(), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testSurvivesRestart() throws Exception
{
createSchema();
///////////////////////////////
// 1. before the restart //
@@ -199,4 +247,71 @@ class PersistenceRestartTest
assertThat(MarketingStore.get(MarketingAgentRun.TABLE_NAME, runId).getValueInteger("status")).isEqualTo(AgentRunStatus.FAILED.getId());
}
/*******************************************************************************
** The daily rollup on the real RDBMS backend: it follows inserts, edits and
** deletes in SQL, the report's COUNT_DISTINCT and SUMs run in the database,
** and the rows survive a restart (and Rebuild Results changes nothing).
*******************************************************************************/
@Test
void testRollupAndImporterOverRdbmsSurviveRestart() throws Exception
{
createSchema();
MeasurementRollupTest.FakeImporter importer = new MeasurementRollupTest.FakeImporter("fake-analytics");
Instant dayOne = Instant.parse("2026-10-01T10:00:00Z");
Instant dayTwo = Instant.parse("2026-10-02T23:59:59Z");
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-1", "x", dayOne, 4, false, null));
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-2", "x", dayTwo, null, false, null));
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-3", "y", dayTwo, null, false, null));
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-4", null, dayTwo, null, true, null));
boot(new MarketingQBitConfig().withOutcomeImporter(importer));
Integer productId = MarketingStore.insert(new MarketingProduct().withName("P").withSlug("p").withMaturity(ProductMaturity.RELEASED.getId())).getValueInteger("id");
Integer campaignId = MarketingStore.insert(new MarketingCampaign().withProductId(productId).withName("C").withGoal(CampaignGoal.AWARENESS.getId())
.withStatus(CampaignStatus.ACTIVE.getId())).getValueInteger("id");
MarketingStore.insert(new MarketingMetric().withCampaignId(campaignId).withStage(FunnelStage.AWARENESS.getId()).withName("Views").withEventType("page_view")
.withCountMode(CountMode.UNIQUE_ACTORS.getId()).withInstrumentationStatus(InstrumentationStatus.INSTRUMENTED.getId()));
OutcomeImportService importService = new OutcomeImportService();
assertThat(importService.importOutcomes(campaignId)).isEqualTo("fake-analytics: 4 imported, 0 already recorded");
assertThat(importService.importOutcomes(campaignId)).isEqualTo("fake-analytics: 0 imported, 4 already recorded");
MeasurementReport.Row views = MeasurementReport.forCampaign(campaignId).getRows().get(0);
assertThat(views.rawEvents()).isEqualTo(6);
assertThat(views.uniqueActors()).as("x on two days and y: distinct in the database").isEqualTo(2);
assertThat(views.eventsWithoutActor()).isZero();
List<QRecord> before = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
assertThat(before).hasSize(3);
QContext.clear();
shutdown();
/////////////////////////////////////
// after the restart: same rows //
/////////////////////////////////////
boot(new MarketingQBitConfig().withOutcomeImporter(importer));
List<QRecord> after = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
assertThat(after).hasSize(3);
assertThat(after).extracting(r -> r.getValueLocalDate("dayUtc") + "|" + r.getValueBoolean("isTestData") + "|" + r.getValueInteger("rawCount") + "|" + r.getValueInteger("uniqueActors")
+ "|" + r.getValueInteger("eventsWithoutActor")).containsExactlyInAnyOrder("2026-10-01|false|4|1|0", "2026-10-02|false|2|2|0", "2026-10-02|true|1|0|1");
assertThat(after).allSatisfy(r -> assertThat(r.getValueInteger("productId")).isEqualTo(productId));
String rebuilt = new MeasurementRollupService().rebuild(campaignId);
assertThat(rebuilt).isEqualTo("rebuilt 3 daily result row(s) over 2 day(s)");
assertThat(MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId)).extracting(r -> r.getValue("id"))
.containsExactlyInAnyOrderElementsOf(after.stream().map(r -> r.getValue("id")).toList());
/////////////////////////////////////////////////////////////
// an edit and a delete are followed in SQL //
/////////////////////////////////////////////////////////////
Integer ga2 = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "externalEventId", "ga-2").get(0).getValueInteger("id");
Integer ga3 = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "externalEventId", "ga-3").get(0).getValueInteger("id");
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", ga2).withValue("occurredAt", dayOne));
MarketingStore.delete(MarketingOutcomeEvent.TABLE_NAME, List.of(ga3));
assertThat(MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId)).extracting(r -> r.getValueLocalDate("dayUtc") + "|" + r.getValueBoolean("isTestData")
+ "|" + r.getValueInteger("rawCount") + "|" + r.getValueInteger("uniqueActors")).containsExactlyInAnyOrder("2026-10-01|false|5|1", "2026-10-02|true|1|0");
MeasurementReport.Row afterViews = MeasurementReport.forCampaign(campaignId).getRows().get(0);
assertThat(afterViews.rawEvents()).isEqualTo(5);
assertThat(afterViews.uniqueActors()).isEqualTo(1);
}
}
@@ -0,0 +1,198 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway.CrmFormSubmissionRef;
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway.CrmRecipient;
import com.kingsrook.qbits.marketing.crm.QbitCrmGateway;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** QbitCrmGateway against fixture tables shaped like qbit-crm 0.2.0's crm_*
** tables.
*******************************************************************************/
class QbitCrmGatewayTest extends BaseMarketingTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testNotAvailableWithoutCrmTables()
{
assertThat(new QbitCrmGateway().isAvailable()).isFalse();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCrmFlows() throws QException
{
defineSnakeCaseTables();
assertThat(QbitCrmGateway.CONTACT_TABLE).isEqualTo("crm_contact");
exerciseGateway();
}
/*******************************************************************************
** Eligibility, recipient scopes, form submissions and activities.
*******************************************************************************/
private void exerciseGateway() throws QException
{
QbitCrmGateway gateway = new QbitCrmGateway();
assertThat(gateway.isAvailable()).isTrue();
Integer granted = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "yes@example.org").withValue("doNotEmail", false));
Integer noConsent = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "none@example.org").withValue("doNotEmail", false));
Integer suppressed = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "dne@example.org").withValue("doNotEmail", true));
for(Integer contactId : List.of(granted, suppressed))
{
insert(QbitCrmGateway.CONSENT_TABLE, new QRecord().withValue("contactId", contactId).withValue("consentType", 1).withValue("status", 1)
.withValue("consentDate", Instant.now().minus(2, ChronoUnit.DAYS)));
}
/////////////////////////////
// checkEmailEligibility //
/////////////////////////////
assertThat(gateway.checkEmailEligibility(granted).eligible()).isTrue();
assertThat(gateway.checkEmailEligibility(noConsent).reason()).isEqualTo("no_email_consent");
assertThat(gateway.checkEmailEligibility(suppressed).reason()).isEqualTo("do_not_email");
assertThat(gateway.checkEmailEligibility(9999).reason()).isEqualTo("contact_not_found");
///////////////////////////////////////
// resolveRecipients: contact scope //
///////////////////////////////////////
List<CrmRecipient> single = gateway.resolveRecipients("crm-contact:" + granted);
assertThat(single).hasSize(1);
assertThat(single.get(0).address()).isEqualTo("yes@example.org");
/////////////////////////////////////
// resolveRecipients: list scope //
/////////////////////////////////////
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", granted).withValue("status", 1));
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", noConsent).withValue("status", 1));
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", suppressed).withValue("status", 2));
List<CrmRecipient> members = gateway.resolveRecipients("crm-list:7");
assertThat(members).hasSize(3);
assertThat(members.stream().filter(r -> r.eligibility().eligible()).map(CrmRecipient::address)).containsExactly("yes@example.org");
assertThat(members.stream().map(r -> r.eligibility().reason())).contains("no_email_consent", "list_member_not_subscribed");
///////////////////////////
// listFormSubmissions //
///////////////////////////
Instant created = Instant.now().minus(1, ChronoUnit.HOURS);
insert(QbitCrmGateway.FORM_SUBMISSION_TABLE, new QRecord().withValue("contactId", granted).withValue("utmCampaign", "spring").withValue("createDate", created));
insert(QbitCrmGateway.FORM_SUBMISSION_TABLE, new QRecord().withValue("contactId", noConsent).withValue("utmCampaign", "other").withValue("createDate", created));
List<CrmFormSubmissionRef> submissions = gateway.listFormSubmissions("spring", null);
assertThat(submissions).hasSize(1);
assertThat(submissions.get(0).contactId()).isEqualTo(granted);
assertThat(gateway.listFormSubmissions("spring", Instant.now())).isEmpty();
//////////////////////
// recordActivity //
//////////////////////
assertThatThrownBy(() -> gateway.recordActivity(granted, "Subject", "Body")).hasMessageContaining("Note");
insert(QbitCrmGateway.ACTIVITY_TYPE_TABLE, new QRecord().withValue("name", "Note"));
gateway.recordActivity(granted, "Subject", "Body");
List<QRecord> activities = MarketingStore.query(QbitCrmGateway.ACTIVITY_TABLE, new QQueryFilter());
assertThat(activities).hasSize(1);
assertThat(activities.get(0).getValueString("subject")).isEqualTo("Subject");
assertThat(activities.get(0).getValueInteger("contactId")).isEqualTo(granted);
}
/*******************************************************************************
**
*******************************************************************************/
private Integer insert(String table, QRecord record) throws QException
{
return (MarketingStore.insert(table, record).getValueInteger("id"));
}
/*******************************************************************************
**
*******************************************************************************/
private void defineSnakeCaseTables()
{
defineCrmTables(QbitCrmGateway.CONTACT_TABLE, QbitCrmGateway.CONSENT_TABLE, QbitCrmGateway.LIST_MEMBER_TABLE,
QbitCrmGateway.FORM_SUBMISSION_TABLE, QbitCrmGateway.ACTIVITY_TABLE, QbitCrmGateway.ACTIVITY_TYPE_TABLE);
}
/*******************************************************************************
** Define fixture tables (memory backend) shaped like qbit-crm's.
*******************************************************************************/
private void defineCrmTables(String contact, String consent, String listMember, String formSubmission, String activity, String activityType)
{
defineTable(contact, field("email", QFieldType.STRING), field("doNotEmail", QFieldType.BOOLEAN), field("unsubscribeDate", QFieldType.DATE_TIME));
defineTable(consent, field("contactId", QFieldType.INTEGER), field("consentType", QFieldType.INTEGER), field("status", QFieldType.INTEGER),
field("consentDate", QFieldType.DATE_TIME), field("expiryDate", QFieldType.DATE_TIME));
defineTable(listMember, field("emailListId", QFieldType.INTEGER), field("contactId", QFieldType.INTEGER), field("status", QFieldType.INTEGER));
defineTable(formSubmission, field("contactId", QFieldType.INTEGER), field("utmCampaign", QFieldType.STRING), field("createDate", QFieldType.DATE_TIME));
defineTable(activity, field("activityTypeId", QFieldType.INTEGER), field("subject", QFieldType.STRING), field("description", QFieldType.STRING),
field("contactId", QFieldType.INTEGER), field("ownerUserId", QFieldType.STRING), field("isCompleted", QFieldType.BOOLEAN), field("completedDate", QFieldType.DATE_TIME));
defineTable(activityType, field("name", QFieldType.STRING));
}
/*******************************************************************************
**
*******************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
return (new QFieldMetaData(name, type));
}
/*******************************************************************************
**
*******************************************************************************/
private void defineTable(String name, QFieldMetaData... fields)
{
QTableMetaData table = new QTableMetaData().withName(name).withBackendName(BACKEND).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER));
for(QFieldMetaData field : fields)
{
table.withField(field);
}
qInstance.addTable(table);
}
}
@@ -0,0 +1,218 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Synchronous scripted responses and request snapshots for marketing tests.
*******************************************************************************/
public class ScriptedModelProvider implements AssistantModelProvider
{
private final String name;
private final Deque<Step> steps = new ArrayDeque<>();
private final List<ModelRequest> requests = new ArrayList<>();
private final List<String> requestJson = new ArrayList<>();
private ModelUsage usagePerCall = new ModelUsage();
/*******************************************************************************
** Register under the name selected by the test configuration.
*******************************************************************************/
public ScriptedModelProvider(String name)
{
this.name = name;
}
/*******************************************************************************
** Queue a complete answer.
*******************************************************************************/
public ScriptedModelProvider thenText(String text)
{
return (thenRespond(request -> new ModelResponse().withParts(List.of(ModelPart.text(text))).withStopReason(ModelResponse.STOP_END_TURN)));
}
/*******************************************************************************
** Queue a single tool call.
*******************************************************************************/
public ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
{
return (thenToolUses(null, List.<Object[]>of(new Object[] { toolName, input })));
}
/*******************************************************************************
** Queue parallel tool calls using the test fixtures' name/input pairs.
*******************************************************************************/
@SuppressWarnings("unchecked")
public ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
{
return (thenRespond(request ->
{
List<ModelPart> parts = new ArrayList<>();
if(preamble != null)
{
parts.add(ModelPart.text(preamble));
}
for(Object[] call : calls)
{
parts.add(ModelPart.toolUse("tool_" + requests.size() + "_" + parts.size(), (String) call[0], (Map<String, Object>) call[1]));
}
return (new ModelResponse().withParts(parts).withStopReason(ModelResponse.STOP_TOOL_USE));
}));
}
/*******************************************************************************
** Queue a provider failure, including usage already incurred.
*******************************************************************************/
public ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
{
return (thenRespond(request ->
{
throw (new ModelProviderException(kind, null, message).withPartialUsage(new ModelUsage().add(usagePerCall)));
}));
}
/*******************************************************************************
** Queue a response that can inspect the request or change test state.
*******************************************************************************/
public ScriptedModelProvider thenRespond(Step step)
{
steps.add(step);
return (this);
}
/*******************************************************************************
** Set usage for budget and cost assertions.
*******************************************************************************/
public ScriptedModelProvider withUsagePerCall(ModelUsage usage)
{
usagePerCall = usage;
return (this);
}
/*******************************************************************************
** Provider registration name.
*******************************************************************************/
@Override
public String getName()
{
return (name);
}
/*******************************************************************************
** Exercise the agent's prompt-cache hints.
*******************************************************************************/
@Override
public ModelCapabilities getCapabilities(String modelId)
{
return (new ModelCapabilities().withPromptCaching(true).withThinking(false));
}
/*******************************************************************************
** Record before responding, preserving each turn before the agent mutates it.
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
requests.add(request.copy());
requestJson.add(JsonUtils.toJson(request));
CompletableStreamHandle handle = new CompletableStreamHandle();
try
{
ModelResponse response = steps.removeFirst().respond(request);
listener.onTextDelta(response.getText());
response.setUsage(new ModelUsage().add(usagePerCall));
response.setRequestId("request_" + requests.size());
handle.complete(response);
}
catch(ModelProviderException e)
{
handle.fail(e);
}
return (handle);
}
/*******************************************************************************
** Request copies for tool exposure assertions.
*******************************************************************************/
List<ModelRequest> getRequests()
{
return (requests);
}
/*******************************************************************************
** Serialized snapshots for data-boundary assertions.
*******************************************************************************/
List<String> getRequestJson()
{
return (requestJson);
}
/*******************************************************************************
** A scripted step may return a response or a checked provider failure.
*******************************************************************************/
@FunctionalInterface
interface Step
{
/*******************************************************************************
** Respond to one model request.
*******************************************************************************/
ModelResponse respond(ModelRequest request) throws ModelProviderException;
}
}
@@ -25,13 +25,17 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleModelProvider;
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -41,8 +45,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The OpenAI-compatible provider against a local fixture server (no network,
** no real key): request mapping, tool calls, usage, error kinds, and the key
** The published OpenAI-compatible provider against a local fixture server
** with no real key: request mapping, tool calls, usage, error kinds, and the key
** never appearing in toString.
*******************************************************************************/
class OpenAiCompatibleModelProviderTest
@@ -94,8 +98,9 @@ class OpenAiCompatibleModelProviderTest
*******************************************************************************/
private OpenAiCompatibleModelProvider provider(String key)
{
return (OpenAiCompatibleModelProvider.builder().name("local").baseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/v1").apiKey(key)
.requestTimeout(Duration.ofSeconds(5)).build());
return (new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OPENAI_COMPATIBLE")
.withBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/v1")
.withApiKey(key == null ? null : new QMetaDataVariableInterpreter().interpret(key)).withRequestTimeoutSeconds(5)));
}
@@ -105,7 +110,7 @@ class OpenAiCompatibleModelProviderTest
*******************************************************************************/
private static ModelRequest request()
{
return (new ModelRequest().withModel("test-model").withMaxTokens(100)
return (new ModelRequest().withModel("test-model").withMaxTokens(100).withStreaming(false)
.withSystem(List.of(new SystemBlock("Be truthful.", null)))
.withTools(List.of(new ModelToolDefinition().withName("marketing_list_channels").withDescription("Channels")
.withInputSchema(Map.of("type", "object", "properties", Map.of()))))
@@ -129,7 +134,11 @@ class OpenAiCompatibleModelProviderTest
"usage":{"prompt_tokens":120,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":20}}}
""" });
ModelResponse response = provider("${env.MARKETING_TEST_KEY_THAT_IS_NOT_SET}").stream(request(), text -> {}).await(Duration.ofSeconds(5));
MarketingQBitConfig config = new MarketingQBitConfig()
.withModelProvider(provider("${env.MARKETING_TEST_KEY_THAT_IS_NOT_SET}"))
.withProviderName("openai-compatible").withModel("configured-model");
ModelResponse response = config.getModelProviders().get(config.getProviderName())
.stream(request().withModel(config.getModel()), text -> {}).await(Duration.ofSeconds(5));
assertThat(response.getStopReason()).isEqualTo(ModelResponse.STOP_TOOL_USE);
assertThat(response.getText()).isEqualTo("Checking.");
assertThat(response.getToolUses()).hasSize(1);
@@ -141,7 +150,7 @@ class OpenAiCompatibleModelProviderTest
String body = lastBody.get();
assertThat(body).contains("\"role\":\"system\"").contains("Be truthful.").contains("\"tool_calls\"").contains("\"role\":\"tool\"").contains("\"tool_call_id\":\"call_1\"")
.contains("\"max_completion_tokens\":100").contains("marketing_list_channels");
.contains("\"max_tokens\":100").contains("marketing_list_channels").contains("\"model\":\"configured-model\"");
assertThat(lastAuth.get()).as("an unset key reference means no Authorization header").isNull();
}
@@ -162,7 +171,8 @@ class OpenAiCompatibleModelProviderTest
.isInstanceOfSatisfying(ModelProviderException.class, e ->
{
assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.AUTHENTICATION);
assertThat(e.getMessage()).contains("401").contains("invalid_api_key").doesNotContain("sk-test");
assertThat(e.getHttpStatus()).isEqualTo(401);
assertThat(e.getMessage()).doesNotContain("sk-FAKE-TEST-KEY-000000");
});
assertThat(lastAuth.get()).isEqualTo("Bearer sk-FAKE-TEST-KEY-000000");
@@ -174,8 +184,9 @@ class OpenAiCompatibleModelProviderTest
assertThatThrownBy(() -> provider.stream(request(), text -> {}).await(Duration.ofSeconds(5)))
.isInstanceOfSatisfying(ModelProviderException.class, e -> assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
responses.add(new Object[] { 200, "{\"choices\":[{\"finish_reason\":\"content_filter\",\"message\":{\"content\":\"\"}}]}" });
assertThat(OpenAiCompatibleModelProvider.toException(503, "").getKind()).isEqualTo(ModelProviderException.Kind.OVERLOADED);
responses.add(new Object[] { 503, "" });
assertThatThrownBy(() -> provider.stream(request(), text -> {}).await(Duration.ofSeconds(5)))
.isInstanceOfSatisfying(ModelProviderException.class, e -> assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.OVERLOADED));
}
@@ -196,4 +207,29 @@ class OpenAiCompatibleModelProviderTest
assertThat(streamed).containsExactly("Done.");
}
/*******************************************************************************
** The agent's default streaming mode receives text and usage from SSE.
*******************************************************************************/
@Test
void testStreaming() throws Exception
{
responses.add(new Object[] { 200, """
data: {"choices":[{"index":0,"delta":{"content":"Done."},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":3}}
data: [DONE]
""" });
List<String> streamed = new ArrayList<>();
ModelResponse response = provider(null).stream(request().withStreaming(true), streamed::add).await(Duration.ofSeconds(5));
assertThat(streamed).containsExactly("Done.");
assertThat(response.getText()).isEqualTo("Done.");
assertThat(response.getUsage().getInputTokens()).isEqualTo(12);
assertThat(response.getUsage().getOutputTokens()).isEqualTo(3);
assertThat(lastBody.get()).contains("\"stream\":true");
}
}
@@ -0,0 +1,317 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.demo;
import java.io.InputStream;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
import com.kingsrook.qbits.marketing.MarketingQBitProducer;
import com.kingsrook.qbits.marketing.MarketingStore;
import com.kingsrook.qbits.marketing.ScriptedModelProvider;
import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
import com.kingsrook.qbits.marketing.model.MarketingChannel;
import com.kingsrook.qbits.marketing.model.MarketingClaim;
import com.kingsrook.qbits.marketing.model.MarketingContent;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
import com.kingsrook.qbits.marketing.model.enums.AgentTask;
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
/*******************************************************************************
** A local demo of the Marketing qbit for screenshots and manual checks (not a
** test, not shipped): an in-memory H2 database created from the shipped DDL
** (src/test/resources/marketing-h2.sql), a mock login as a user holding every
** marketing permission, the verified starter data, and some history made
** through the real services: a readiness check, an agent draft (offline,
** scripted model), review, approval, scheduling, a controlled test execution
** with its attempt and test delivery, and a few outcome events.
**
** Run it (from the repository root), then the Next UI dev server against it:
**
** ./mvnw -q test-compile
** ./mvnw -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test
** java -cp "target/test-classes;target/classes;$(cat target/cp.txt)" com.kingsrook.qbits.marketing.demo.MarketingDemoServer
**
** (Use ':' instead of ';' in the classpath on Linux/macOS. The server listens
** on port 8000, or MARKETING_DEMO_PORT.)
**
** QQQ_BACKEND_URL=http://localhost:8000 pnpm dev (in qqq-frontend-next; port 3000)
*******************************************************************************/
public class MarketingDemoServer extends AbstractQQQApplication
{
static final String BACKEND = "demo";
static final String USER = "dana";
private static final String BODY = "QQQ 4.0 is on Maven Central: you can try it with the quickstart in a few minutes. Java 21, Docker and Bash are required.";
/*******************************************************************************
** Start the demo.
*******************************************************************************/
public static void main(String[] args) throws Exception
{
MarketingDemoServer application = new MarketingDemoServer();
new QApplicationJavalinServer(application)
.withServeFrontendNext(false)
.withServeFrontendMaterialDashboard(false)
.withPort(Integer.parseInt(System.getenv().getOrDefault("MARKETING_DEMO_PORT", "8000")))
.start();
}
/*******************************************************************************
** The demo instance (seeded on definition).
*******************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
QInstance qInstance = new QInstance();
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(BACKEND).withVendor("h2").withHostName("mem").withDatabaseName("marketing_demo").withUsername("sa");
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
qInstance.addBackend(backend);
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
qInstance.setBranding(new QBrandingMetaData().withAppName("Marketing demo").withCompanyName("QRun-IO"));
qInstance.setEnforcePermissionsInCoreActions(true);
createTables(backend);
ScriptedModelProvider model = new ScriptedModelProvider("scripted").withUsagePerCall(new ModelUsage().withInputTokens(1200L).withOutputTokens(300L));
new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig().withBackendName(BACKEND).withModelProvider(model).withModel("scripted-model")).produce(qInstance);
seed(qInstance, model);
return (qInstance);
}
/*******************************************************************************
** Create the tables from the shipped H2 DDL.
*******************************************************************************/
static void createTables(RDBMSBackendMetaData backend) throws QException
{
try(Connection connection = ConnectionManager.getConnection(backend); Statement statement = connection.createStatement();
InputStream ddl = MarketingDemoServer.class.getResourceAsStream("/marketing-h2.sql"))
{
String sql = new String(ddl.readAllBytes(), StandardCharsets.UTF_8);
for(String part : sql.split(";"))
{
String trimmed = part.lines().filter(l -> !l.trim().startsWith("--")).reduce("", (a, b) -> a + "\n" + b).trim();
if(!trimmed.isEmpty())
{
statement.execute(trimmed);
}
}
}
catch(Exception e)
{
throw (new QException("Could not create the demo tables", e));
}
}
/*******************************************************************************
** Starter data, then history through the real services and processes.
*******************************************************************************/
static void seed(QInstance qInstance, ScriptedModelProvider model) throws QException
{
try
{
QContext.init(qInstance, DemoSessionCustomizer.sessionFor(USER));
////////////////////////////////////////////////////////////////
// the qbit's own "Load Verified Starter Data" table action //
////////////////////////////////////////////////////////////////
requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.LOAD_STARTER_DATA)
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "starter data");
QRecord product = first(MarketingProduct.TABLE_NAME, "slug", "qqq");
QRecord campaign = first(MarketingCampaign.TABLE_NAME, "name", "QQQ 4.0: from quickstart to your first table");
QRecord sink = first(MarketingChannel.TABLE_NAME, "name", "Controlled test sink");
Integer campaignId = campaign.getValueInteger("id");
requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, campaignId), "campaign readiness");
requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, first(MarketingCampaign.TABLE_NAME, "name", "gclo 1.1 for Windows: mirror a whole GitHub org").getValue("id")), "campaign readiness");
///////////////////////////////////////////////////////////////////////////
// Draft with Agent, offline: the scripted model reads, then saves a draft //
///////////////////////////////////////////////////////////////////////////
Integer released = MarketingStore.query(MarketingClaim.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("productId", QCriteriaOperator.EQUALS, product.getValue("id")),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ClaimStatus.RELEASED.getId()))).get(0).getValueInteger("id");
model.thenToolUses("Reading the brief first.", List.of(
new Object[] { MarketingTools.GET_CAMPAIGN_BRIEF, Map.of("campaign_id", campaignId) },
new Object[] { MarketingTools.LIST_PRODUCT_CLAIMS, Map.of("product_id", product.getValueInteger("id")) },
new Object[] { MarketingTools.LIST_CHANNELS, Map.of() }))
.thenToolUse(MarketingTools.SAVE_DRAFT, Map.of("campaign_id", campaignId, "channel_id", sink.getValueInteger("id"), "title", "Try QQQ 4.0 in a few minutes",
"body", BODY, "claim_ids", List.of(released)))
.thenText("I saved one draft for review on the controlled test sink.");
new MarketingAgentService().run(AgentTask.DRAFT_CONTENT, campaignId, null, null);
///////////////////////////////////////////////////////////////////////////
// a person schedules it, submits it, approves it and schedules it; the //
// worker then runs the due execution on the controlled test sink //
///////////////////////////////////////////////////////////////////////////
Integer contentId = first(MarketingContent.TABLE_NAME, "campaignId", campaignId).getValueInteger("id");
MarketingStore.update(MarketingContent.TABLE_NAME, new QRecord().withValue("id", contentId).withValue("scheduledFor", Instant.now().minusSeconds(5)));
requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, contentId), "submit for review");
requireNoException(runAction(MarketingActions.APPROVE, contentId), "approve");
requireNoException(runAction(MarketingActions.SCHEDULE, contentId), "schedule");
requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.RUN_DUE_EXECUTIONS)
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "run due executions");
///////////////////////////////////////////////////
// a second piece of content, waiting for review //
///////////////////////////////////////////////////
Integer waiting = MarketingStore.insert(new MarketingContent().withCampaignId(campaignId).withChannelId(sink.getValueInteger("id")).withTitle("Your first table, step by step")
.withBody("The quickstart takes you from an empty project to your first table. Java 21, Docker and Bash are required.")
.withScheduledFor(Instant.now().plusSeconds(3600L * 24))).getValueInteger("id");
requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, waiting), "submit for review");
/////////////////////////////////////////////////////////////////////////
// controlled fixture events (test data, never counted as real numbers) //
/////////////////////////////////////////////////////////////////////////
for(int i = 1; i <= 5; i++)
{
MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.AWARENESS.getId()).withEventType("try_page_visit")
.withSource("fixture").withExternalEventId("visit-" + i).withActorKey("visitor-" + (i % 3)).withIsTestData(true));
}
MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.ENGAGEMENT.getId()).withEventType("quickstart_opened")
.withSource("fixture").withExternalEventId("open-1").withActorKey("visitor-1").withIsTestData(true));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The first record matching one field.
*******************************************************************************/
private static QRecord first(String table, String field, Serializable value) throws QException
{
return (MarketingStore.queryBy(table, field, value).get(0));
}
/*******************************************************************************
** Run a record action headlessly on one record.
*******************************************************************************/
private static RunProcessOutput runAction(String processName, Serializable id) throws QException
{
return (new RunProcessAction().execute(new RunProcessInput().withProcessName(processName).withCallback(QProcessCallbackFactory.forPrimaryKey("id", id))
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)));
}
/*******************************************************************************
** Fail the demo start-up if a seeding step failed.
*******************************************************************************/
private static void requireNoException(RunProcessOutput output, String what) throws QException
{
if(output.getException().isPresent())
{
throw (new QException("Demo seeding failed at [" + what + "]", output.getException().get()));
}
}
/*******************************************************************************
** Every mock session is Dana, who holds every marketing permission.
*******************************************************************************/
public static class DemoSessionCustomizer implements QAuthenticationModuleCustomizerInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
QSession granted = sessionFor(USER);
qSession.setUser(granted.getUser());
qSession.setPermissions(granted.getPermissions());
}
/***************************************************************************
**
***************************************************************************/
@Override
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession automatedSessionForUser, Serializable userId)
{
automatedSessionForUser.setPermissions(sessionFor(String.valueOf(userId)).getPermissions());
}
/***************************************************************************
** Dana's session.
***************************************************************************/
static QSession sessionFor(String userId)
{
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Dana Demo"));
session.setPermissions(new java.util.HashSet<>(MarketingPermissions.ALL));
return (session);
}
}
}
@@ -0,0 +1,290 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.email;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
import com.kingsrook.qbits.marketing.MarketingQBitProducer;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qbits.marketing.transport.MarketingTransport.Recipient;
import com.kingsrook.qbits.marketing.transport.MarketingTransport.TransportRequest;
import com.kingsrook.qbits.marketing.transport.MarketingTransport.TransportResult;
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** QbitEmailTransport against a real qbit-email outbox (memory backend).
*******************************************************************************/
class QbitEmailTransportTest
{
private static final String BACKEND = "memory";
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
}
/*******************************************************************************
** An instance with marketing and (optionally) the email qbit.
*******************************************************************************/
private QInstance instance(boolean withEmail, String publicBaseUrl, String unsubscribeKey, MarketingQBitConfig marketingConfig) throws Exception
{
MemoryRecordStore.getInstance().reset();
QInstance qInstance = new QInstance();
qInstance.addBackend(new QBackendMetaData().withName(BACKEND).withBackendType(MemoryBackendModule.class));
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
if(withEmail)
{
EmailQBitConfig emailConfig = new EmailQBitConfig().withBackendName(BACKEND).withPublicBaseUrl(publicBaseUrl)
.withSecretResolver(name -> "QQQ_EMAIL_UNSUBSCRIBE_KEY".equals(name) ? unsubscribeKey : null);
new EmailQBitProducer().withQBitConfig(emailConfig).produce(qInstance);
}
new MarketingQBitProducer().withQBitConfig(marketingConfig.withBackendName(BACKEND)).produce(qInstance);
new QInstanceValidator().validate(qInstance);
List<String> permissions = new ArrayList<>(MarketingPermissions.ALL);
permissions.addAll(List.of("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess"));
QContext.init(qInstance, new QSession().withPermissions(permissions));
return (qInstance);
}
/*******************************************************************************
**
*******************************************************************************/
private void provider() throws QException
{
brand();
insert("email_provider", new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1")
.withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true));
}
/*******************************************************************************
** Marketing email needs a default brand with a postal address.
*******************************************************************************/
private void brand() throws QException
{
insert("email_brand", new QRecord().withValue("name", "default").withValue("label", "Example").withValue("isDefault", true)
.withValue("postalAddress", "1 Main St, Springfield"));
}
/*******************************************************************************
** Configure the email qbit's editable tables through ordinary QQQ actions.
*******************************************************************************/
private void insert(String table, QRecord record) throws QException
{
var output = new InsertAction().execute(new InsertInput(table).withRecords(List.of(record)));
assertThat(output.getRecords()).hasSize(1).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
}
/*******************************************************************************
**
*******************************************************************************/
private TransportRequest request(String key, Recipient... recipients)
{
return (new TransportRequest(key, 7, 1, "Email", ChannelType.EMAIL, "Big news", "Hello **there**.", "https://example.org/try", "Try it", List.of(recipients)));
}
/*******************************************************************************
** One MARKETING message per recipient with the right key and link; a repeat
** is DUPLICATE; the qqq_email name resolves to the transport.
*******************************************************************************/
@Test
void testSendsOneMarketingMessagePerRecipient() throws Exception
{
instance(true, "https://app.example.org", "k", new MarketingQBitConfig());
provider();
MarketingTransport transport = MarketingRuntime.current().getTransport(QqqMessagingEmailTransport.DEFAULT_NAME);
assertThat(transport).isInstanceOf(QbitEmailTransport.class);
assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.OPERATIONAL);
assertThat(transport.getSupportedChannelTypes()).containsExactly(ChannelType.EMAIL);
TransportRequest request = request("marketing-execution:3:4", new Recipient("crm-contact:1", "a@example.org"), new Recipient("crm-contact:2", "b@example.org"));
TransportResult result = transport.send(request);
assertThat(result.message()).as(result.message()).isNotNull();
assertThat(result.outcome()).as(result.message()).isEqualTo(TransportResult.Outcome.ACCEPTED);
assertThat(result.providerStatus()).isEqualTo("queued");
List<EmailApiMessage> rows = EmailApi.findMessagesByIdempotencyKeyPrefix("marketing:3:4:", 10);
assertThat(rows).hasSize(2);
assertThat(rows).extracting(EmailApiMessage::idempotencyKey).containsExactlyInAnyOrder("marketing:3:4:crm-contact:1", "marketing:3:4:crm-contact:2");
assertThat(rows).allSatisfy(r ->
{
assertThat(r.category()).isEqualTo(EmailApiCategory.MARKETING);
assertThat(r.linkedTableName()).isEqualTo("marketing_execution");
assertThat(r.linkedRecordId()).isEqualTo("7");
assertThat(r.templateName()).isNull();
assertThat(r.status()).isEqualTo("QUEUED");
});
// The public API intentionally omits content; inspect it as an email admin.
List<QRecord> messages = new QueryAction().execute(new QueryInput("email_message")
.withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false)).getRecords();
assertThat(messages).hasSize(2).allSatisfy(r ->
{
assertThat(r.getValueString("subject")).isEqualTo("Big news");
assertThat(r.getValueString("senderQbit")).isEqualTo("marketing");
assertThat(r.getValueString("senderProcess")).isEqualTo("execution");
assertThat(r.getValueString("htmlBody")).contains("https://example.org/try").contains("nsubscribe");
assertThat(r.getValueString("toAddresses")).doesNotContain(",");
});
assertThat(transport.send(request).outcome()).isEqualTo(TransportResult.Outcome.DUPLICATE);
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("marketing:3:4:", 10)).hasSize(2);
assertThat(transport.lookup("marketing-execution:3:4").state()).isEqualTo(MarketingTransport.TransportLookup.State.FOUND_ACCEPTED);
assertThat(transport.lookup("marketing-execution:9:9").state()).isEqualTo(MarketingTransport.TransportLookup.State.NOT_FOUND);
}
/*******************************************************************************
** A suppressed address is skipped, not failed; with every recipient
** suppressed the result is still ACCEPTED (all_suppressed).
*******************************************************************************/
@Test
void testSuppressedIsSkipped() throws Exception
{
instance(true, "https://app.example.org", "k", new MarketingQBitConfig());
provider();
insert("email_suppression", new QRecord().withValue("address", "gone@example.org").withValue("reason", "UNSUBSCRIBED").withValue("scope", "MARKETING"));
MarketingTransport transport = new QbitEmailTransport();
TransportResult result = transport.send(request("marketing-execution:1:1", new Recipient("crm-contact:9", "gone@example.org")));
assertThat(result.outcome()).isEqualTo(TransportResult.Outcome.ACCEPTED);
assertThat(result.providerStatus()).isEqualTo("all_suppressed");
assertThat(result.message()).contains("1 skipped as suppressed");
TransportResult mixed = transport.send(request("marketing-execution:2:1", new Recipient("crm-contact:9", "gone@example.org"), new Recipient("crm-contact:10", "ok@example.org")));
assertThat(mixed.providerStatus()).isEqualTo("queued");
assertThat(mixed.message()).contains("1 queued, 1 skipped");
}
/*******************************************************************************
** Without the unsubscribe key or public base URL the transport is not
** configured and says why; a refusal by the email qbit is REJECTED with
** its reason.
*******************************************************************************/
@Test
void testNotConfiguredAndRefused() throws Exception
{
instance(true, "https://app.example.org", null, new MarketingQBitConfig());
QbitEmailTransport transport = new QbitEmailTransport();
assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.NOT_CONFIGURED);
assertThat(transport.getStatus().detail()).contains("QQQ_EMAIL_UNSUBSCRIBE_KEY");
assertThat(transport.send(request("k", new Recipient("a", "a@example.org"))).outcome()).isEqualTo(TransportResult.Outcome.RETRYABLE_FAILURE);
instance(true, null, "k", new MarketingQBitConfig());
assertThat(new QbitEmailTransport().getStatus().detail()).contains("publicBaseUrl");
instance(true, "https://app.example.org", "k", new MarketingQBitConfig());
assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.OPERATIONAL);
TransportResult refused = new QbitEmailTransport().send(request("marketing-execution:5:5", new Recipient("a", "a@example.org")));
assertThat(refused.outcome()).isEqualTo(TransportResult.Outcome.REJECTED);
assertThat(refused.message()).contains("refused the message");
provider();
TransportResult noAddress = new QbitEmailTransport().send(request("marketing-execution:6:6", new Recipient("a", null)));
assertThat(noAddress.outcome()).isEqualTo(TransportResult.Outcome.REJECTED);
}
/*******************************************************************************
** A missing postal address is a send-time refusal, not NOT_CONFIGURED.
*******************************************************************************/
@Test
void testMissingBrandIsRefusedAtSend() throws Exception
{
instance(true, "https://app.example.org", "k", new MarketingQBitConfig());
insert("email_provider", new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1")
.withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true));
QbitEmailTransport transport = new QbitEmailTransport();
assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.OPERATIONAL);
TransportResult result = transport.send(request("marketing-execution:8:8", new Recipient("a", "a@example.org")));
assertThat(result.outcome()).isEqualTo(TransportResult.Outcome.REJECTED);
assertThat(result.message()).contains("postal address");
}
/*******************************************************************************
** Without the email qbit in the instance the name does not resolve to the
** transport, and a host-registered transport always wins.
*******************************************************************************/
@Test
void testFallbackAndHostTransportWin() throws Exception
{
instance(false, null, null, new MarketingQBitConfig());
assertThat(MarketingRuntime.current().getTransport(QqqMessagingEmailTransport.DEFAULT_NAME)).isNull();
assertThat(new QbitEmailTransport().getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.NOT_CONFIGURED);
QqqMessagingEmailTransport host = new QqqMessagingEmailTransport().withMessagingProviderName("email").withFromAddress("n@example.org");
instance(true, "https://app.example.org", "k", new MarketingQBitConfig().withTransport(host));
assertThat(MarketingRuntime.current().getTransport(QqqMessagingEmailTransport.DEFAULT_NAME)).isSameAs(host);
}
}
@@ -0,0 +1,6 @@
bundle: test-host-override
version: "1"
layer: HOST
entries:
- key: table:marketing_claim;slot:help
content: host text
+23
View File
@@ -314,6 +314,7 @@ CREATE TABLE IF NOT EXISTS marketing_agent_run (
model VARCHAR(100),
requested_by_user_id VARCHAR(100),
started_at TIMESTAMP,
heartbeat_at TIMESTAMP,
finished_at TIMESTAMP,
model_calls INTEGER,
tool_calls INTEGER,
@@ -433,6 +434,28 @@ CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_campaign_id ON marketing_o
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_content_id ON marketing_outcome_event (content_id);
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_execution_id ON marketing_outcome_event (execution_id);
-- A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).
CREATE TABLE IF NOT EXISTS marketing_metric_daily (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
campaign_id INTEGER NOT NULL,
product_id INTEGER,
day_utc DATE NOT NULL,
funnel_stage INTEGER NOT NULL,
event_type VARCHAR(100) NOT NULL,
is_test_data BOOLEAN NOT NULL,
raw_count INTEGER,
unique_actors INTEGER,
events_without_actor INTEGER,
recomputed_at TIMESTAMP,
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_marketing_metric_daily PRIMARY KEY (id),
CONSTRAINT uq_marketing_metric_daily_1 UNIQUE (campaign_id, day_utc, funnel_stage, event_type, is_test_data)
);
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_campaign_id ON marketing_metric_daily (campaign_id);
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_product_id ON marketing_metric_daily (product_id);
-- Runtime switches and limits: agent and execution kill switches, provider, model, budgets, concurrency.
CREATE TABLE IF NOT EXISTS marketing_policy (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,