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 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
46 changed files with 5203 additions and 175 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.9
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
+12 -1
View File
@@ -4,7 +4,15 @@ 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.
@@ -13,9 +21,12 @@ All notable changes to this project are documented here. The format follows Keep
- 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.
+14 -3
View File
@@ -22,8 +22,8 @@ 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-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. |
@@ -98,6 +98,16 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
- 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.
@@ -116,9 +126,10 @@ When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is pr
| 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 |
| 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 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).
+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.
+12 -4
View File
@@ -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**
+9 -2
View File
@@ -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.
@@ -83,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**.
@@ -98,6 +101,10 @@ These are real limitations of 0.1.0. Nothing here is claimed to be production-sc
- **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.
+22
View File
@@ -441,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,
+13 -4
View File
@@ -18,22 +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 provider SPI (com.kingsrook.qbits.assistant.spi) only: no assistant runtime. -->
<qbit-assistant.version>0.1.0-RC.12</qbit-assistant.version>
<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.3</qbit-email.version>
<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.1.2</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -105,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>
@@ -130,6 +137,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-marketing.properties</include>
</includes>
</resource>
@@ -137,6 +145,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-marketing.properties</exclude>
</excludes>
</resource>
@@ -200,7 +209,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
+5
View File
@@ -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"]],
@@ -24,6 +24,7 @@ import java.util.Map;
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 //
@@ -27,6 +27,7 @@ import java.util.concurrent.locks.ReentrantLock;
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;
@@ -318,6 +319,17 @@ 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);
}
@@ -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.
*******************************************************************************/
@@ -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,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 //
@@ -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
@@ -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");
}
@@ -253,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();
}
}
@@ -54,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");
@@ -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);
}
}
@@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Synchronous scripted responses and request snapshots for marketing tests.
*******************************************************************************/
class ScriptedModelProvider implements AssistantModelProvider
public class ScriptedModelProvider implements AssistantModelProvider
{
private final String name;
private final Deque<Step> steps = new ArrayDeque<>();
@@ -51,7 +51,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Register under the name selected by the test configuration.
*******************************************************************************/
ScriptedModelProvider(String name)
public ScriptedModelProvider(String name)
{
this.name = name;
}
@@ -61,7 +61,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a complete answer.
*******************************************************************************/
ScriptedModelProvider thenText(String text)
public ScriptedModelProvider thenText(String text)
{
return (thenRespond(request -> new ModelResponse().withParts(List.of(ModelPart.text(text))).withStopReason(ModelResponse.STOP_END_TURN)));
}
@@ -71,7 +71,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a single tool call.
*******************************************************************************/
ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
public ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
{
return (thenToolUses(null, List.<Object[]>of(new Object[] { toolName, input })));
}
@@ -82,7 +82,7 @@ class ScriptedModelProvider implements AssistantModelProvider
** Queue parallel tool calls using the test fixtures' name/input pairs.
*******************************************************************************/
@SuppressWarnings("unchecked")
ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
public ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
{
return (thenRespond(request ->
{
@@ -104,7 +104,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a provider failure, including usage already incurred.
*******************************************************************************/
ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
public ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
{
return (thenRespond(request ->
{
@@ -117,7 +117,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a response that can inspect the request or change test state.
*******************************************************************************/
ScriptedModelProvider thenRespond(Step step)
public ScriptedModelProvider thenRespond(Step step)
{
steps.add(step);
return (this);
@@ -128,7 +128,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Set usage for budget and cost assertions.
*******************************************************************************/
ScriptedModelProvider withUsagePerCall(ModelUsage usage)
public ScriptedModelProvider withUsagePerCall(ModelUsage usage)
{
usagePerCall = usage;
return (this);
@@ -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,6 @@
bundle: test-host-override
version: "1"
layer: HOST
entries:
- key: table:marketing_claim;slot:help
content: host text
+22
View File
@@ -434,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,