Compare commits

...
32 Commits
Author SHA1 Message Date
james.maes fe7a8b5089 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#42) from deps/align-gitea into develop
build / maven (push) Successful in 1m46s
maven / build (push) Successful in 1m46s
2026-11-07 19:50:21 +00:00
James MaesandClaude Opus 5.5 51ca0bb920 refactor(health): register CRM checks with the core health registry
build / maven (push) Successful in 1m36s
maven / build (push) Successful in 1m36s
Replace the CrmHealthChecks STAND-IN (qqq#1199) adapter onto the
qqq-middleware-health HealthIndicator with checks registered in the QQQ
4.1 core HealthChecksMetaData registry (crm.email, crm.sequence_lag,
crm.lead_score). The core HealthRunner schedules them and runs each as
the core system user. Drop the optional qqq-middleware-health dependency.

Also keep the lead-score incremental watermark at millisecond precision
to match modifyDate, fixing PlatformReuseTest skipping a same-millisecond
change on a warm JVM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:40:26 -06:00
james.maesandClaude Opus 5.5 d0f558dc60 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Failing after 3m25s
maven / build (push) Failing after 3m24s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.7 -> 4.1.0-RC.26
pom.xml: qbit-email: 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:56:32 +00:00
james.maes 945f01371a Merge pull request 'ci: pin shared workflow to v1' (#41) 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 17s
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 15s
build / maven (push) Successful in 4m34s
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 4m34s
2026-11-06 16:05:12 +00:00
james.maesandClaude Opus 5.5 021e86c202 ci: pin shared workflow to v1
build / maven (push) Successful in 4m4s
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
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) Successful in 7s
maven / build (push) Successful in 4m4s
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: native / native-result (pull_request) Successful in 9s
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:07:01 +00:00
james.maes a48933bedc Merge pull request 'ci: use shared ci-workflows Maven workflow' (#40) 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: 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 / node-8 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
build / maven (push) Successful in 3m10s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 10s
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 3m10s
2026-11-05 23:15:36 +00:00
james.maesandClaude Opus 5.5 c9acd0921c 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: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m59s
build / maven (push) Successful in 3m28s
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 2s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 16m48s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
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: native / native-result (pull_request) Successful in 6s
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
maven / build (push) Successful in 3m28s
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:03:32 +00:00
forge-recovery 60f8ced9bb ci: enroll shared Gitea lifecycle
qrun-release / postpublish (push) Failing after 10m38s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
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 40s
QRun-IO/ci-policy: native / native-result (push) Successful in 11s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 48s
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: Shared Java Maven CI / verify (push) Successful in 2m41s
Shared Java Maven CI / verify (push) Successful in 3m49s
Shared QRun source version / version (push) Failing after 10m38s
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
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 18s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 25m14s
Shared Java Maven CI / gate (push) Successful in 16s
qrun-release / release (push) Successful in 25m43s
2026-11-02 22:12:06 +00:00
forge-recovery 746d2edd17 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
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 55s
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 8s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 3m6s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 7s
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
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 18s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 8m54s
2026-11-02 21:31:17 +00:00
forge-recoveryandCodex ee6ee59793 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 / 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 19s
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 2m57s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 58m28s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
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 4s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:14:46 +00:00
409e900f10 ci: add the isolated postpublish job to the QRun release caller
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 / 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
Shared Java Maven CI / secrets (push) Successful in 19s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 20s
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
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m42s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 2h44m34s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
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 / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h39m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:48:04 +00:00
9094e9a760 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 / not-enrolled (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: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 3s
Shared Java Maven CI / secrets (push) Successful in 30s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 25s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m21s
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 / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 1h16m0s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h16m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:17:14 +00:00
forge-recoveryandCodex 72bc4267e6 ci: add the identical QRun release caller
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: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m43s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 0s
QRun-IO/ci-policy: ci / ci-result (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 / java-maven-4 (pull_request) Failing after 17m30s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:09:26 +00:00
james.maesandCodex d6f21f6594 ci: adopt managed orb 0.8 patch track (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:47 -05:00
james.maesandCodex 00f798668a ci: adopt shared orb 0.7.18 (#34)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:42 -05:00
james.maesandCodex a7b42339b9 ci: adopt shared orb 0.7.17 (#33)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:25 -05:00
james.maesandCodex e2990bcd60 ci: centralize QBit source scanning (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:45:40 -05:00
james.maesandCodex e422092fd4 ci: adopt central orb 0.7.12 and stable gates (#28)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:07 -05:00
james.maesandCodex 70f841c256 ci: centralize rc release completion in qqq orb (#27)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:01 -05:00
james.maesandCodex 730bb68428 ci: adopt shared orb 0.7.11 (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:56:34 -05:00
james.maes 62c393374d Merge pull request #25 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:20:28 -05:00
james.maesandCodex c380dce8b6 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:42 -05:00
james.maes a8e9a34b51 Merge pull request #22 from QRun-IO/feature/crm-setup-admin-only
fix(crm): Setup card only for settings holders while setup is incomplete
2026-10-16 10:19:22 -05:00
james.maesandClaude Sonnet 5.5 26b8847b46 fix(crm): show the Setup card only to CRM settings holders while setup is incomplete
The crm_setup widget was the 2nd widget on the CRM home, rendered to every
CRM user even when complete ("Nothing to do"), and carried a provider row
(Email delivery: sends through the email qbit). That is system/config content
on a business home.

- crm_setup now requires crm.manage (the permission guarding the CRM Settings
  tables) via widget rules, plus CrmSetupVisibility, a request-time custom
  permission checker (same mechanism as crm_goals) that denies once no
  business step is missing. Denied widgets are omitted (DenyBehavior.HIDDEN)
  from metadata and render.
- Removed the Email delivery row; the checklist is business setup only.
- Help text updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:10:51 -05:00
james.maes 820474805d Merge pull request #21 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:03:28 -05:00
james.maesandClaude Opus 5.5 5157f81208 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:22:11 -05:00
james.maes c6031c26ed Merge pull request #20 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 08e880af2e fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-crm.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:22:58 -05:00
james.maes e3f20d357e Merge pull request #18 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 4710ed38b3 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:05:02 -05:00
james.maes 0ebbeefa08 Merge pull request #17 from QRun-IO/feature/help-bundle
feat(help): qbit-crm help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 c5383718a3 feat(help): ship the qbit-crm help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:27:08 -05:00
23 changed files with 2724 additions and 1895 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
+8
View File
@@ -2,6 +2,14 @@
## Unreleased / 0.2.0
- Register CRM health checks (`crm.email`, `crm.sequence_lag`, `crm.lead_score`) with the QQQ 4.1 core health registry (`HealthChecksMetaData`, qqq#1199), replacing the `qqq-middleware-health` indicator stand-in and its optional dependency.
- Lead scoring keeps its incremental watermark at millisecond precision, matching `modifyDate`, so a contact changed in the same millisecond a pass starts is not skipped.
- Read the CRM QBit metadata version from the Maven-filtered artifact version so release metadata matches the build.
- Adopt QQQ 4.1.0-RC.7 native help bundles, replacing the RC.6 stand-in loader with host-neutral CRM help, live references and the core coverage gate.
- Upgrade to Java 21, QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.2.
- Add Maven wrapper, CI configuration, architecture guardrails and coverage gates.
- Rename tables, joins, widgets and CRM processes to `crm_` snake_case, with inferred snake_case SQL columns.
+20 -2
View File
@@ -62,9 +62,9 @@ qInstance.setEnforcePermissionsInCoreActions(true);
- `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields.
- `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads.
- `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables.
- `CrmHealthChecks.indicators(monitoringSession)`: register the returned indicators with the host health middleware. Include optional `qqq-middleware-health`; the supplied session determines tenant scope and is restored on worker threads. `run()` uses the caller's context. No check adds permissions or tenant keys.
- Health: the producer registers `crm.email`, `crm.sequence_lag` and `crm.lead_score` with the core health registry (`HealthChecksMetaData`). Hosts that enable core health monitoring (`HealthMetaDataProvider`) get them scheduled by the core `HealthRunner`, which runs each check as the core system user. `CrmHealthChecks.run()` evaluates them once in the caller's context. No check adds permissions or tenant keys or returns personal values.
The shipped help bundle and health adapter are temporary core integration adapters; their removal target is RC.9. See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
Help and health both use the core registries (`HelpBundles`, `HealthChecksMetaData`). See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
| Scope | Permissions |
|---|---|
@@ -174,6 +174,24 @@ Later phases complete the following behavior:
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase.
## In-app help
CRM registers [help/qbit-crm.yaml](src/main/resources/help/qbit-crm.yaml) from
`CrmQBitProducer`. The bundle uses the `QBIT` layer and the current artifact version.
Registration only adds metadata and works without a `helpContent` table; QQQ's
startup sync skips hosts without that table. Hosts with help storage load help
and call `HelpBundles.syncOnStartup(qInstance)` after defining their metadata.
To customize wording or describe your deployment, register a separate `HOST`
bundle with the same entry keys (and roles). HOST text takes precedence over QBIT
text; the sync preserves user edits for review. Keep live `{{...}}` references
when referring to labels, defaults or enum values.
`CrmPublicationHelpTest` runs `HelpBundleAssertions` against the produced and
validated CRM instance, including generated record actions, and also requires
widget and possible-value help. `CrmTablesGuardrailTest` checks every table's
row-creation guidance. Run both through `./mvnw -q verify`.
## Source layout
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
+11 -11
View File
@@ -89,8 +89,8 @@ These parts are real and are kept, adapted to 0.2.0:
| Imports | `importContacts` | core bulk load + saved bulk-load profiles |
| PII | none | `DataClassification.PII` on every personal field; secured-data tiers apply |
| Reporting | 14 bespoke widgets | home widgets for the daily view; trend reports via data analysis (`crm_*.analyze`) |
| Health | none | `crm` health checks (stand-in until core #1199) |
| Help | none | `help/qbit-crm.yaml` bundle (stand-in loader until core #1198) |
| Health | none | `crm.*` checks in the core health registry (`HealthChecksMetaData`) |
| Help | none | `help/qbit-crm.yaml` bundle registered with core `HelpBundles` |
| Tenancy | clientId lock on 3 tables | configurable tenant key type on every table; the host maps Authentik claims to keys |
## Data model (33 tables: 6 main, 15 settings, 12 sub-tables)
@@ -268,13 +268,14 @@ signals are logged.
### Health
`CrmHealthChecks` reports three checks:
- email transport readiness
- sequence lag (the oldest due enrollment more than 30 min late)
- the scoring job's last success time
`CrmHealthChecks` registers three checks with the core health registry
(`HealthChecksMetaData`, component `crm`):
- `crm.email`: email transport readiness
- `crm.sequence_lag`: the oldest due enrollment more than 30 min late
- `crm.lead_score`: the scoring job's last success time
It is a `STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)` adapter onto the
`qqq-middleware-health` `HealthIndicator` until the central `HealthCheck` SPI ships.
The core `HealthRunner` schedules them on hosts that enable health monitoring and runs each one
as the core system user. A failing check reports UNKNOWN with a fixed message.
### Help
@@ -282,9 +283,8 @@ It is a `STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)` adapter onto the
- the app
- every table, field, action and possible-value source
`HelpBundleAssertions` checks that it is complete. Until `HelpBundles.register` is in a core RC,
`CrmHelpBundleStandIn` (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)`) loads the same
file into `QHelpContent`.
The producer registers it with core `HelpBundles.register`; `HelpBundleAssertions` checks that it
is complete.
### Contingent dropdowns
+33 -9
View File
@@ -22,6 +22,7 @@
<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.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -33,7 +34,7 @@
<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>
@@ -78,16 +79,11 @@
<artifactId>qqq-middleware-api</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-health</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>0.1.0-RC.3</version>
<version>0.1.0-RC.11</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -135,7 +131,35 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-crm.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-crm.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -204,14 +228,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -26,9 +26,12 @@
package com.kingsrook.qbits.crm;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.customizers.CurrencyRateDateCustomizer;
@@ -43,7 +46,7 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealProduct;
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qbits.crm.health.CrmHealthChecks;
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
import com.kingsrook.qbits.crm.security.CrmPermissions;
@@ -66,6 +69,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
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.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
@@ -86,12 +90,40 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "crm";
public static final String VERSION = "0.2.0-SNAPSHOT";
public static final String VERSION = loadVersion();
private CrmQBitConfig qBitConfig;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CrmQBitProducer.class.getClassLoader().getResourceAsStream("qbit-crm.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-crm.properties; build with Maven to provide the artifact version");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-crm.properties; build with Maven to provide the artifact version");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read artifact version from qbit-crm.properties", e);
}
}
/*******************************************************************************
** Produce the QBitMetaData for the CRM QBit.
*******************************************************************************/
@@ -120,6 +152,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
}
CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig);
CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig);
CrmHealthChecks.register(qInstance, qBitConfig);
for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
{
widget.setName(snakeCase(widget.getName()));
@@ -384,7 +417,8 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
}
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
registerHooks(qInstance);
CrmHelpBundleStandIn.apply(output);
// Registration is metadata-only; core startup sync guards hosts without helpContent.
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
return (output);
}
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.crm.health;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.CrmQBitConfig;
@@ -32,9 +33,15 @@ import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheck;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
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;
@@ -42,24 +49,25 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.middleware.health.HealthIndicator;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)
** CRM checks for hosts to register with their health middleware. Run in the
** caller's tenant context when one is supplied, else as the core system user;
** no check returns personal values.
** CRM checks registered with the core health registry (HealthChecksMetaData).
** The core HealthRunner schedules them and runs each one in its own automated
** system-user context; no check returns personal values.
*******************************************************************************/
public class CrmHealthChecks
{
public static final String EMAIL = "crm.email";
public static final String SEQUENCE_LAG = "crm.sequence_lag";
public static final String LEAD_SCORE = "crm.lead_score";
private static final String COMPONENT = "crm";
private final CrmEmailTransport transport;
/*******************************************************************************
** Use the same readiness decision as actual email delivery.
*******************************************************************************/
@@ -69,6 +77,7 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Allow a host transport adapter.
*******************************************************************************/
@@ -78,121 +87,141 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Run all checks without registering tables or starting background workers.
** Register the CRM checks with the instance's core health registry.
** Production may run repeatedly; an already registered name is kept.
*******************************************************************************/
public Map<String, HealthCheckResult> run() throws QException
public static void register(QInstance instance, CrmQBitConfig config)
{
return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore()));
new CrmHealthChecks(config).registerWith(instance);
}
/*******************************************************************************
** Expose individual checks through the RC.6 health SPI. With no caller
** session (registration at startup), each check runs as the core system
** user: token-based authentication modules (OAuth2, Auth0) reject any other
** tokenless session, and the checks report only UP/DEGRADED, never values.
** Register these checks (with this transport) with the core health registry.
*******************************************************************************/
public List<HealthIndicator> indicators()
public void registerWith(QInstance instance)
{
return (indicators(QContext.getQSession()));
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
add(registry, EMAIL, "CRM email transport", HealthCategory.EMAIL, 300);
add(registry, SEQUENCE_LAG, "CRM sequence lag", HealthCategory.JOBS, 300);
add(registry, LEAD_SCORE, "CRM lead scoring", HealthCategory.JOBS, 900);
}
/*******************************************************************************
** Supply the host's monitoring session explicitly for middleware worker threads.
** Its permissions and tenant keys are preserved; this adapter never grants keys.
** A null session runs each check as the core system user.
*******************************************************************************/
public List<HealthIndicator> indicators(QSession session)
{
return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session)));
}
/*******************************************************************************
** One independently evaluated middleware indicator.
** Run all checks once in the caller's context, without registering anything.
*******************************************************************************/
private class Indicator implements HealthIndicator
public Map<String, HealthCheckOutcome> run() throws QException
{
private final String name;
private final QSession session;
/*******************************************************************************
** Select a check.
*******************************************************************************/
private Indicator(String name, QSession session)
Map<String, HealthCheckOutcome> outcomes = new LinkedHashMap<>();
for(String name : List.of(EMAIL, SEQUENCE_LAG, LEAD_SCORE))
{
this.name = name;
this.session = session;
outcomes.put(name, check(name).run(new HealthCheckInput()));
}
return (outcomes);
}
@Override
public String getName()
/*******************************************************************************
** The core SPI body for one named check. Failures are reported as UNKNOWN
** with a fixed message, so no exception text (or value) leaves the check.
*******************************************************************************/
public HealthCheck check(String name)
{
return (new HealthCheck()
{
return (name);
}
@Override
public HealthCheckResult check(QInstance instance)
{
CapturedContext original = QContext.capture();
try
/*******************************************************************
** Evaluate the named check in the current QContext.
*******************************************************************/
@Override
public HealthCheckOutcome run(HealthCheckInput input)
{
QContext.init(instance, session != null ? session : new QSystemUserSession(instance));
return (switch(name)
try
{
case "crm.email" -> email();
case "crm.sequenceLag" -> sequenceLag();
default -> leadScore();
});
return (switch(name)
{
case EMAIL -> email();
case SEQUENCE_LAG -> sequenceLag();
case LEAD_SCORE -> leadScore();
default -> throw (new IllegalArgumentException("Unknown CRM health check"));
});
}
catch(QException | RuntimeException e)
{
return (HealthCheckOutcome.unknown("CRM check could not be completed"));
}
}
catch(QException | RuntimeException e)
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed"));
}
finally
{
QContext.init(original);
}
}
});
}
/*******************************************************************************
** Add one check definition unless the name is already registered.
*******************************************************************************/
private void add(HealthChecksMetaData registry, String name, String label, HealthCategory category, int intervalSeconds)
{
if(registry.getChecks().stream().anyMatch(existing -> name.equals(existing.getName())))
{
return;
}
registry.withCheck(new HealthCheckMetaData()
.withName(name)
.withLabel(label)
.withComponent(COMPONENT)
.withCategory(category)
.withKind(HealthCheckKind.DEPENDENCY)
.withSeverity(AlertSeverity.WARNING)
.withIntervalSeconds(intervalSeconds)
.withTimeoutMs(5000)
.withCheckInstance(check(name)));
}
/*******************************************************************************
** An unconfigured transport is degraded, not a claim that email can be sent.
*******************************************************************************/
private HealthCheckResult email() throws QException
private HealthCheckOutcome email() throws QException
{
return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP));
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED
? HealthCheckOutcome.degraded("Email transport not configured")
: HealthCheckOutcome.up("Email transport ready"));
}
/*******************************************************************************
** Query just the oldest active due enrollment.
*******************************************************************************/
private HealthCheckResult sequenceLag() throws QException
private HealthCheckOutcome sequenceLag() throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId()))
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN, Instant.now().minus(30, ChronoUnit.MINUTES)))
.withOrderBy(new QFilterOrderBy("nextStepDate")).withLimit(1))).getRecords();
return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED));
return (rows.isEmpty()
? HealthCheckOutcome.up("No sequence step is more than 30 minutes late")
: HealthCheckOutcome.degraded("A sequence step is more than 30 minutes late"));
}
/*******************************************************************************
** No successful score pass, or one more than three hours old, needs attention.
*******************************************************************************/
private HealthCheckResult leadScore() throws QException
private HealthCheckOutcome leadScore() throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME).withFilter(new QQueryFilter(
new QFilterCriteria("name", QCriteriaOperator.EQUALS, CalculateLeadScoresProcess.NAME)).withLimit(1))).getRecords();
Instant last = rows.isEmpty() ? null : rows.getFirst().getValueInstant("lastSuccess");
return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
? HealthStatus.DEGRADED : HealthStatus.UP));
return (last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
? HealthCheckOutcome.degraded("No lead score pass succeeded in the last 3 hours")
: HealthCheckOutcome.up("Lead scores are current"));
}
}
@@ -1,135 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.crm.help;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)
** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks
** process/PVS help slots: action content is applied to frontend steps instead;
** enum entries remain in the bundle for the central loader.
*******************************************************************************/
public final class CrmHelpBundleStandIn
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmHelpBundleStandIn()
{
}
/*******************************************************************************
** Validate the resource eagerly so malformed shipped help fails production.
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml"))
{
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1
|| !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("Invalid CRM bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120
|| entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("Invalid or duplicate help entry: " + key));
}
}
return (entries);
}
catch(IOException | IllegalArgumentException e)
{
throw (new QException("Could not load help/qbit-crm.yaml", e));
}
}
/*******************************************************************************
** Apply help to the objects produced by this QBit only.
*******************************************************************************/
public static void apply(MetaDataProducerMultiOutput output) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : output.getEach(QTableMetaData.class))
{
table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help"));
for(var field : table.getFields().values())
{
String key = "table:" + table.getName() + ";field:" + field.getName();
if(entries.containsKey(key))
{
field.withHelpContent(content(entries, key));
}
}
}
for(QProcessMetaData process : output.getEach(QProcessMetaData.class))
{
QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help");
for(var step : process.getStepList())
{
if(step instanceof QFrontendStepMetaData frontend)
{
frontend.withHelpContent(help.clone());
}
}
}
for(QAppMetaData app : output.getEach(QAppMetaData.class))
{
app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help"));
}
}
/*******************************************************************************
** RC.6 has no summary property, so retain the summary in the bundle only.
*******************************************************************************/
private static QHelpContent content(Map<String, JsonNode> entries, String key) throws QException
{
if(!entries.containsKey(key))
{
throw (new QException("Missing CRM help entry: " + key));
}
return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText()));
}
}
@@ -105,7 +105,11 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Instant startedAt = Instant.now();
////////////////////////////////////////////////////////////////////////
// modifyDate is kept at millisecond precision; a microsecond watermark //
// would skip a change made in the same millisecond the pass started. //
////////////////////////////////////////////////////////////////////////
Instant startedAt = Instant.now().truncatedTo(ChronoUnit.MILLIS);
List<QRecord> states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords();
QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst();
@@ -76,6 +76,11 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
{
widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class));
}
if("crm_setup".equals(name))
{
widget.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.MANAGE_BASE))
.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmSetupVisibility.class));
}
return (widget);
}
}
@@ -23,8 +23,6 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -89,6 +87,16 @@ public final class CrmSetupStatus
/*******************************************************************************
** True when any business setup step still needs attention.
*******************************************************************************/
public static boolean isIncomplete() throws QException
{
return (load().stream().anyMatch(item -> item.state() == State.TODO));
}
/*******************************************************************************
** Build the items for the current user.
*******************************************************************************/
@@ -160,14 +168,6 @@ public final class CrmSetupStatus
? new Item("Base currency", "crm_currency", State.TODO, "None: amounts have no currency prefix and conversions do nothing.", "Add your currency with Base currency ticked and rate 1.")
: new Item("Base currency", "crm_currency", State.OK, base.getFirst().getValueString("currencyCode") + " is the base currency.", null));
///////////////////////////////////////////////////////
// email: sequences and list mail need a delivery path //
///////////////////////////////////////////////////////
CrmReadiness readiness = CrmApi.readiness();
items.add(readiness == CrmReadiness.NOT_CONFIGURED
? new Item("Email delivery", "crm_email_template", State.OPTIONAL, "No email path: sequences and list email cannot send.",
"Install and configure the email qbit, or set a messaging provider on the CRM configuration.")
: new Item("Email delivery", "crm_email_template", State.OK, readiness == CrmReadiness.QBIT_EMAIL ? "Sends through the email qbit." : "Sends through core messaging.", null));
return (items);
}
@@ -0,0 +1,64 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.crm.widgets;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
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.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Show the Setup card only to CRM settings holders (crm.manage) and only while
** a business setup step is still missing. Evaluated at request time, so a
** completed setup drops the card from per-session metadata and from render.
*******************************************************************************/
public class CrmSetupVisibility implements CustomPermissionChecker
{
/*******************************************************************************
** Require crm.hasAccess and crm.manage.hasAccess, then an incomplete setup.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS) || !QContext.getQSession().hasPermission(CrmPermissions.MANAGE))
{
throw (new QPermissionDeniedException("CRM settings access is required."));
}
try
{
if(CrmSetupStatus.isIncomplete())
{
return;
}
}
catch(QException e)
{
throw (new QPermissionDeniedException("Setup status is not available.", e));
}
throw (new QPermissionDeniedException("CRM setup is complete."));
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
version=${project.version}
@@ -33,14 +33,19 @@ import com.kingsrook.qbits.crm.health.CrmHealthChecks;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -51,27 +56,41 @@ import static org.assertj.core.api.Assertions.assertThat;
class CrmHealthBulkLoadTest extends BaseTest
{
/*******************************************************************************
** No transport or score run needs attention.
** No transport or score run needs attention; the producer registered the
** same checks with the core health registry.
*******************************************************************************/
@Test
void noTransportOrScoreRunNeedsAttention() throws Exception
{
var checks = new CrmHealthChecks(new CrmQBitConfig());
var results = checks.run();
assertThat(results.get("crm.email").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.UP);
checks.indicators().forEach(indicator ->
assertThat(results.get(CrmHealthChecks.EMAIL).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.UP);
for(HealthCheckMetaData registered : crmChecks(QContext.getQInstance()))
{
try
{
assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
assertThat(registered.getCheckInstance().run(new HealthCheckInput()).getStatus()).isEqualTo(results.get(registered.getName()).getStatus());
}
}
/*******************************************************************************
** The producer registers exactly the three CRM checks as valid core
** definitions, and re-registration does not duplicate them.
*******************************************************************************/
@Test
void producerRegistersCoreHealthChecks()
{
QInstance instance = QContext.getQInstance();
assertThat(crmChecks(instance)).extracting(HealthCheckMetaData::getName)
.containsExactlyInAnyOrder(CrmHealthChecks.EMAIL, CrmHealthChecks.SEQUENCE_LAG, CrmHealthChecks.LEAD_SCORE);
assertThat(crmChecks(instance)).allSatisfy(check ->
{
assertThat(check.getCheckInstance()).isNotNull();
assertThat(check.getTimeoutMs()).isLessThan(check.getIntervalSeconds() * 1000);
});
CrmHealthChecks.register(instance, new CrmQBitConfig());
assertThat(crmChecks(instance)).hasSize(3);
}
@@ -101,8 +120,8 @@ class CrmHealthBulkLoadTest extends BaseTest
.withValue("lastSuccess", Instant.now().minus(181, ChronoUnit.MINUTES)));
com.kingsrook.qbits.crm.core.CrmRecords.update("crm_sequence_enrollment", new QRecord().withValue("id", 1)
.withValue("nextStepDate", Instant.now().minus(31, ChronoUnit.MINUTES)));
assertThat(checks.run().get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(checks.run().get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(checks.run().get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(checks.run().get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
}
@@ -120,7 +139,7 @@ class CrmHealthBulkLoadTest extends BaseTest
throw (new IllegalStateException("unavailable"));
}
});
assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
assertThat(checks.check(CrmHealthChecks.EMAIL).run(new HealthCheckInput()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
}
@@ -163,27 +182,36 @@ class CrmHealthBulkLoadTest extends BaseTest
}
/*******************************************************************************
** Health middleware invokes checks in worker threads without a caller QContext.
** The core HealthRunner runs each check on a worker thread with no caller
** QContext, as a QSystemUserSession. A registered CRM check must succeed that
** way on a host whose authentication rejects tokenless sessions (OAuth2, Auth0).
*******************************************************************************/
@Test
void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception
void registeredChecksRunAsCoreSystemUserOnTokenRequiredHost() throws Exception
{
var instance = QContext.getQInstance();
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1);
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("token-required")
.withType(QAuthenticationType.MOCK)
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
HealthCheckMetaData sequenceLag = crmChecks(instance).stream()
.filter(check -> CrmHealthChecks.SEQUENCE_LAG.equals(check.getName())).findFirst().orElseThrow();
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
{
assertThat(QContext.getQInstance()).isNull();
QContext.init(instance, new QSystemUserSession(instance));
try
{
assertThat(QContext.getQInstance()).isNull();
var result = indicator.check(instance);
assertThat(QContext.getQInstance()).isNull();
assertThat(QContext.getQSession()).isNull();
return (result.getStatus());
return (sequenceLag.getCheckInstance().run(new HealthCheckInput().withQInstance(instance)).getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
finally
{
QContext.clear();
}
}).get();
assertThat(status).isEqualTo(HealthStatus.UP);
assertThat(QContext.getQInstance()).isSameAs(instance);
@@ -192,32 +220,12 @@ class CrmHealthBulkLoadTest extends BaseTest
/*******************************************************************************
** Regression: indicators registered with no caller session (at host startup)
** must still run on a host whose authentication rejects tokenless sessions
** (OAuth2, Auth0). They used a plain new QSession(), so every check failed
** session validation and reported UNKNOWN.
** The CRM definitions in the core health registry.
*******************************************************************************/
@Test
void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception
private static List<HealthCheckMetaData> crmChecks(QInstance instance)
{
var instance = QContext.getQInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("token-required")
.withType(QAuthenticationType.MOCK)
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
{
try
{
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1);
return (indicator.check(instance).getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
}).get();
assertThat(status).isEqualTo(HealthStatus.UP);
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
.filter(check -> "crm".equals(check.getComponent())).toList());
}
}
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.crm;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction;
import com.kingsrook.qqq.api.model.APIVersion;
import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput;
@@ -33,8 +32,8 @@ import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -101,52 +100,29 @@ class CrmPublicationHelpTest extends BaseTest
/*******************************************************************************
** Help covers app every table main field process and enum.
** Native registration is safe without help tables and covers all CRM metadata.
*******************************************************************************/
@Test
void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception
void helpCoversOwnedMetadataWithoutHelpTable()
{
var entries = CrmHelpBundleStandIn.entries();
var instance = QContext.getQInstance();
assertThat(entries).containsKey("app:crm;slot:help");
for(var table : instance.getTables().values())
assertThat(instance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(entries).containsKey("table:" + table.getName() + ";slot:help");
assertThat(table.getHelpContent().get("help")).isNotEmpty();
if(CrmAppMetaData.MAIN_TABLES.contains(table.getName()))
{
for(var field : table.getFields().values())
{
assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName());
assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty();
}
}
}
for(var process : instance.getProcesses().values())
{
if(process.getName().startsWith("crm_"))
{
assertThat(entries).containsKey("process:" + process.getName() + ";slot:help");
// Core creates dotted bulk-process names after QBit production; they retain core screen help.
if(!process.getName().contains("."))
{
process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast)
.forEach(step -> assertThat(step.getHelpContents()).isNotEmpty());
}
}
}
for(var pvs : instance.getPossibleValueSources().values())
{
if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm"))
{
assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help");
}
}
entries.values().forEach(entry ->
{
assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank();
assertThat(entry.get("content").asText()).isNotBlank();
assertThat(bundle.getId()).isEqualTo("qbit-crm");
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
});
HelpBundles.syncOnStartup(instance);
HelpBundleAssertions.forInstance(instance)
.bundles("help/qbit-crm.yaml")
.owning(target -> target.name().equals("crm") || target.name().startsWith("crm_") || target.name().startsWith("Crm"))
.assertComplete();
// Core's gate validates these entries but does not require widget or possible-value coverage.
var entries = HelpBundles.of(instance).resolveWinningEntries();
instance.getWidgets().keySet().stream().filter(name -> name.startsWith("crm_")).forEach(name ->
assertThat(entries).containsKey(new HelpBundles.EntryKey("widget:" + name + ";slot:label", "ALL_SCREENS")));
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("crm_") || name.startsWith("Crm")).forEach(name ->
assertThat(entries).containsKey(new HelpBundles.EntryKey("possibleValueSource:" + name + ";slot:help", "ALL_SCREENS")));
}
}
@@ -62,7 +62,7 @@ class CrmQBitProducerTest extends BaseTest
assertThat(metaData).isNotNull();
assertThat(metaData.getGroupId()).isEqualTo(CrmQBitProducer.GROUP_ID);
assertThat(metaData.getArtifactId()).isEqualTo(CrmQBitProducer.ARTIFACT_ID);
assertThat(metaData.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
}
@@ -114,7 +114,7 @@ class CrmQBitProducerTest extends BaseTest
{
assertThat(CrmQBitProducer.GROUP_ID).isEqualTo("com.kingsrook.qbits");
assertThat(CrmQBitProducer.ARTIFACT_ID).isEqualTo("crm");
assertThat(CrmQBitProducer.VERSION).isEqualTo("0.2.0-SNAPSHOT");
assertThat(CrmQBitProducer.VERSION).isEqualTo(System.getProperty("qbit.artifactVersion"));
}
/*******************************************************************************
@@ -31,8 +31,7 @@ import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
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;
@@ -160,18 +159,17 @@ class CrmTablesGuardrailTest extends BaseTest
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
assertThat(CrmHelpBundleStandIn.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
HelpBundles.of(qInstance()).getBundles().getFirst().getEntries()
.forEach(entry -> assertThat(entry.getSummary()).as(entry.getKey()).hasSizeLessThanOrEqualTo(120));
for(String table : crmTables())
{
List<QHelpContent> help = qInstance().getTable(table).getHelpContent() == null ? null : qInstance().getTable(table).getHelpContent().get("help");
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("**How rows get created:**");
assertThat(tableHelp(table)).as(table + " help says how rows get created").contains("**How rows get created:**");
}
for(String table : NOT_WRITTEN_YET)
{
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table + " says it is not used yet").contains("not used yet");
assertThat(tableHelp(table)).as(table + " says it is not used yet").contains("not used yet");
}
assertThat(qInstance().getApp("crm").getHelpContent()).containsKey("help");
assertThat(HelpBundles.of(qInstance()).resolveWinningEntries()).containsKey(new HelpBundles.EntryKey("app:crm;slot:help", "ALL_SCREENS"));
}
@@ -184,14 +182,26 @@ class CrmTablesGuardrailTest extends BaseTest
{
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
{
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table).contains("seeds");
assertThat(tableHelp(table)).as(table).contains("seeds");
}
}
/*******************************************************************************
**
** Read the winning table entry from the registered native bundle.
*******************************************************************************/
private String tableHelp(String table)
{
var entry = HelpBundles.of(qInstance()).resolveWinningEntries().get(new HelpBundles.EntryKey("table:" + table + ";slot:help", "ALL_SCREENS"));
assertThat(entry).as(table + " table help").isNotNull();
return (entry.entry().getContent());
}
/*******************************************************************************
** Test instance after CRM production.
*******************************************************************************/
private com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance()
{
@@ -23,12 +23,21 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
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.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -38,19 +47,18 @@ import static org.assertj.core.api.Assertions.assertThat;
class CrmSetupStatusTest extends BaseTest
{
/*******************************************************************************
** Nothing configured: the four things the CRM needs are flagged.
** Nothing configured: the business setup the CRM needs are flagged.
*******************************************************************************/
@Test
void emptyInstallNeedsSetup() throws Exception
{
Map<String, CrmSetupStatus.Item> items = items();
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency", "Email delivery");
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency");
assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails");
assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity");
assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Email delivery").state()).isNotEqualTo(CrmSetupStatus.State.TODO);
}
@@ -106,7 +114,7 @@ class CrmSetupStatusTest extends BaseTest
{
assertThat(CrmHomeWidgets.HOME).contains("crm_setup");
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows()).hasSize(5);
assertThat(data.getRows()).hasSize(4);
CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline");
assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup");
@@ -114,6 +122,76 @@ class CrmSetupStatusTest extends BaseTest
/*******************************************************************************
** The checklist is business setup only: no email-provider row.
*******************************************************************************/
@Test
void noEmailProviderRow() throws Exception
{
assertThat(items().keySet()).noneMatch(label -> label.toLowerCase().contains("email"));
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows().toString()).doesNotContain("email qbit").doesNotContain("Email delivery");
}
/*******************************************************************************
** Settings holders get the card while setup is incomplete, and not once complete.
*******************************************************************************/
@Test
void settingsHolderSeesCardOnlyWhileIncomplete() throws Exception
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isTrue();
completeSetup();
assertThat(CrmSetupStatus.isIncomplete()).isFalse();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** A CRM user without the settings permission never gets the card, incomplete or not.
*******************************************************************************/
@Test
void nonSettingsUserNeverSeesCard() throws Exception
{
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE));
assertThat(CrmSetupStatus.isIncomplete()).isTrue();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Render the setup widget the way the UI API does, with permissions enforced.
*******************************************************************************/
private void renderEnforced() throws Exception
{
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_setup"));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new RenderWidgetAction().execute(input);
}
/*******************************************************************************
** Seed everything the checklist asks for.
*******************************************************************************/
private void completeSetup() throws Exception
{
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
}
/*******************************************************************************
**
*******************************************************************************/