Compare 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
22 changed files with 330 additions and 637 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 -14
View File
@@ -1,26 +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
secrets: inherit
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
+3 -2
View File
@@ -2,8 +2,9 @@
## Unreleased / 0.2.0
- Add explicit shared email-template opt-in using qbit-email 0.1.0-RC.10. Email owns rendering and checked outbox handoff; CRM retains caller-visible variables, delivery categories and exact sent-subject history. Legacy template metadata is omitted only for opted-in hosts; tenant hosts retain the existing path.
- Adopt QQQ 4.1.0-RC.20 and register native CRM health checks while preserving legacy health adapters and caller/tenant context.
- 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.
+2 -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 |
|---|---|
-13
View File
@@ -1,18 +1,5 @@
# Upgrading
## Shared email templates (Unreleased / 0.2.0)
This version uses public qbit-email `0.1.0-RC.10` and QQQ `4.1.0-RC.20`. Shared templates remain disabled by default. Existing optional-email and tenant-enabled hosts retain their legacy template behavior.
For a non-tenant host adopting shared templates:
1. Install the email qbit and configure its template permission base as `crm.manage`. Existing per-template edit permissions and record locks remain enforced; this grant does not expose other email settings.
2. Before enabling `CrmQBitConfig.withSharedEmailTemplates(true)`, migrate legacy template content into email-owned `CRM_LEGACY` templates and active versions, remap sequence/history and generic record references, and retain source provenance and rollback data. This library does not run a host database migration. Populated deployments require a documented CRM maintenance window with writes and workers quiesced, fresh preflight counts and verified backups.
3. Enable the opt-in only after migration. It omits `crm_email_template` metadata and its possible-value source entirely and uses `email_template` for actions and sequences. Tenant mode and missing email installations reject the opt-in.
4. Native email templates retain their category and content policies. Migrated compatibility templates preserve historical substitutions and overrides. Checked replay can reject a changed template version or envelope; reconcile the existing effect rather than silently retrying with a new idempotency key.
The producer also registers `crm.email`, `crm.sequence-lag` and `crm.lead-score` with core health metadata. Existing legacy adapters remain available. Checks use execution-time context, preserve tenant visibility and do not assign tenant keys to contextless workers.
## Starter defaults (Unreleased / 0.2.0)
The CRM now ships `db/liquibase/qbit-crm/crm-defaults.changelog.sql`, a separate insert-only changelog. Without it,
+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
+5 -10
View File
@@ -22,8 +22,8 @@
<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>
<qbit-email.version>0.1.0-RC.10</qbit-email.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -34,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>
@@ -79,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>${qbit-email.version}</version>
<version>0.1.0-RC.11</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -233,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.20 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.20</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -67,15 +67,14 @@ public final class CrmAppMetaData
*******************************************************************************/
public static QAppMetaData build(MetaDataProducerMultiOutput output, String source)
{
List<String> settings = SETTINGS_TABLES.stream().filter(name -> output.get(QTableMetaData.class, name) != null).toList();
QAppMetaData app = new QAppMetaData().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
.withWidgets(new ArrayList<>(CrmHomeWidgets.HOME))
.withSection(mainSection())
.withSection(new QAppSection().withName("crm_settings").withLabel("Settings").withIcon(new QIcon("settings"))
.withTables(new ArrayList<>(settings)));
.withTables(new ArrayList<>(SETTINGS_TABLES)));
List<String> tables = new ArrayList<>(MAIN_TABLES);
tables.addAll(settings);
tables.addAll(SETTINGS_TABLES);
for(String name : tables)
{
app.withChild(output.get(QTableMetaData.class, name));
@@ -41,7 +41,6 @@ public class CrmQBitConfig implements QBitConfig
{
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
private boolean sharedEmailTemplates;
private String tenantSecurityKeyType;
private String tenantAllAccessKeyName;
private String apiName;
@@ -364,22 +363,4 @@ public class CrmQBitConfig implements QBitConfig
}
/*******************************************************************************
** Opt-in only after the host has migrated legacy template references.
*******************************************************************************/
public boolean getSharedEmailTemplates()
{
return (sharedEmailTemplates);
}
/*******************************************************************************
** Other hosts keep the legacy optional-email and tenant behavior by default.
*******************************************************************************/
public CrmQBitConfig withSharedEmailTemplates(boolean sharedEmailTemplates)
{
this.sharedEmailTemplates = sharedEmailTemplates;
return (this);
}
}
@@ -46,6 +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.health.CrmHealthChecks;
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
import com.kingsrook.qbits.crm.security.CrmPermissions;
@@ -72,7 +73,6 @@ 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;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
@@ -152,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()));
@@ -392,28 +393,10 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
if(qBitConfig.getSharedEmailTemplates() && (qBitConfig.getTenantSecurityKeyType() != null || qInstance.getTable("email_template") == null))
{
throw (new QException("Shared CRM templates require installed qbit-email and a non-tenant CRM host"));
}
MetaDataProducerMultiOutput produced = QBitMetaDataProducer.super.produce(qInstance);
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
for(MetaDataProducerOutput item : produced.getEach(MetaDataProducerOutput.class))
{
if(qBitConfig.getSharedEmailTemplates() && ((item instanceof QTableMetaData table && "crm_email_template".equals(table.getName()))
|| (item instanceof QPossibleValueSource source && "crm_email_template".equals(source.getName()))))
{
continue;
}
if(qBitConfig.getSharedEmailTemplates() && item instanceof QTableMetaData table && table.getFields().containsKey("emailTemplateId"))
{
table.getField("emailTemplateId").setPossibleValueSourceName("email_template");
}
if(qBitConfig.getSharedEmailTemplates() && item instanceof QProcessMetaData process)
{
process.getInputFields().stream().filter(field -> "emailTemplateId".equals(field.getName()))
.forEach(field -> field.setPossibleValueSourceName("email_template"));
}
output.add(item instanceof QWidgetMetaData widget
? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
}
@@ -435,7 +418,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
registerHooks(qInstance);
// Registration is metadata-only; core startup sync guards hosts without helpContent.
com.kingsrook.qbits.crm.health.CrmHealthChecks.register(qInstance, qBitConfig);
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
return (output);
}
@@ -375,11 +375,7 @@ public final class CrmAction
}
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
{
if("emailTemplateId".equals(field.getName()) && transport != null && transport.usesSharedTemplates())
{
transport.templateVariableNames(input.getValueInteger("emailTemplateId"));
}
else if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
{
return (field.getLabel() + " is unavailable");
}
@@ -21,11 +21,8 @@
package com.kingsrook.qbits.crm.email;
import java.io.Serializable;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
@@ -126,58 +123,6 @@ public class CrmEmailTransport
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
}
/*******************************************************************************
** Shared storage/rendering is explicitly enabled only after host migration.
*******************************************************************************/
public boolean usesSharedTemplates()
{
return (config.getSharedEmailTemplates());
}
/*******************************************************************************
** Only declared inputs are computed and passed to the email-owned renderer.
*******************************************************************************/
public Set<String> templateVariableNames(Integer templateId) throws QException
{
return (EmailApi.templateVariableNames(templateId));
}
/*******************************************************************************
** Keep optional email API types behind this adapter boundary.
*******************************************************************************/
public record TemplateDelivery(String subject, Delivery delivery)
{
}
/*******************************************************************************
** Checked selection, then native outbox rendering retains all send policies.
*******************************************************************************/
public TemplateDelivery sendTemplateMessage(String purpose, String id, Integer contactId, String address, Integer templateId,
String subject, String body, boolean marketing, Map<String, Serializable> variables) throws QException
{
if(!emailInstalled())
{
throw (new QUserFacingException("Shared CRM templates require qbit-email"));
}
String key = "crm:" + purpose + ":" + id;
EmailApiRequest request = new EmailApiRequest().withTo(address)
.withCategory(marketing ? EmailApiCategory.MARKETING : EmailApiCategory.TRANSACTIONAL)
.withSubject(subject == null || subject.isBlank() ? null : subject)
.withMarkdownBody(body == null || body.isBlank() ? null : body)
.withLinkedRecord("crm_contact", contactId).withSender("crm", purpose).withIdempotencyKey(key);
variables.forEach(request::withVariable);
var checked = EmailApi.sendTemplateOnce(templateId, request, key);
var result = checked.effect();
if(result.outcome() == EmailApiEffectOutcome.REFUSED)
{
throw (new QUserFacingException("The email service could not accept this template message."));
}
var message = result.message();
Delivery delivery = message == null ? new Delivery(null, CrmEmailStatus.QUEUED, null, null, null, null, null)
: new Delivery(String.valueOf(message.id()), status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null);
return (new TemplateDelivery(checked.subject(), delivery));
}
/*******************************************************************************
** CRM-owned delivery snapshot; optional email types never escape this adapter.
*******************************************************************************/
@@ -21,12 +21,8 @@
package com.kingsrook.qbits.crm.email;
import java.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.crm.api.ActivityKind;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
@@ -85,43 +81,28 @@ public final class CrmEmails
recordActivity(existing.getFirst());
return;
}
CrmEmailTransport.Delivery delivery;
if(templateId != null && transport.usesSharedTemplates())
if(templateId != null)
{
Set<String> names = new HashSet<>(transport.templateVariableNames(templateId));
String overrides = String.valueOf(subject) + body;
Set.of("contact.firstName", "contact.lastName", "company.name", "owner.name", "unsubscribeUrl").stream()
.filter(name -> overrides.contains("{{" + name + "}}")).forEach(names::add);
var sent = transport.sendTemplateMessage(purpose, id, contact.getValueInteger("id"), contact.getValueString("email"),
templateId, subject, body, enrollmentId != null, templateVariables(contact, names));
subject = sent.subject();
delivery = sent.delivery();
QRecord template = GetAction.execute("crm_email_template", templateId);
if(template == null)
{
throw (new QUserFacingException("Email template is unavailable"));
}
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
if(body == null)
{
body = template.getValueString("bodyHtml");
}
}
else
if(subject == null || subject.isBlank() || body == null || body.isBlank())
{
if(templateId != null)
{
QRecord template = GetAction.execute("crm_email_template", templateId);
if(template == null)
{
throw (new QUserFacingException("Email template is unavailable"));
}
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
if(body == null)
{
body = template.getValueString("bodyHtml");
}
}
if(subject == null || subject.isBlank() || body == null || body.isBlank())
{
throw (new QUserFacingException("Email subject and body are required"));
}
subject = render(subject, contact);
body = render(body, contact);
delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
contact.getValueString("email"), subject, body, enrollmentId != null);
throw (new QUserFacingException("Email subject and body are required"));
}
subject = render(subject, contact);
body = render(body, contact);
CrmEmailTransport.Delivery delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
contact.getValueString("email"), subject, body, enrollmentId != null);
QRecord message = CrmRecords.insert("crm_email_message", new QRecord().withValue("contactId", contact.getValue("id"))
.withValue("emailTemplateId", templateId).withValue("sequenceEnrollmentId", enrollmentId)
.withValue("toAddresses", contact.getValue("email")).withValue("subject", subject)
@@ -155,29 +136,6 @@ public final class CrmEmails
.withValue("activityId", activities.getFirst().getValue("id")));
}
/*******************************************************************************
** Values come from the caller's visible CRM records, never a system query.
*******************************************************************************/
private static Map<String, Serializable> templateVariables(QRecord contact, Set<String> names) throws QException
{
QRecord company = !names.contains("company.name") && !names.contains("company") || contact.getValue("companyId") == null
? null : GetAction.execute("crm_company", contact.getValue("companyId"));
Map<String, Serializable> available = new LinkedHashMap<>();
available.put("contact.firstName", value(contact, "firstName"));
available.put("contact.lastName", value(contact, "lastName"));
available.put("company.name", value(company, "name"));
available.put("owner.name", value(contact, "ownerUserId"));
available.put("contact", new LinkedHashMap<>(Map.of("firstName", value(contact, "firstName"), "lastName", value(contact, "lastName"))));
available.put("company", new LinkedHashMap<>(Map.of("name", value(company, "name"))));
available.put("owner", new LinkedHashMap<>(Map.of("name", value(contact, "ownerUserId"))));
if(names.contains("unsubscribeUrl"))
{
available.put("unsubscribeUrl", new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null));
}
available.keySet().retainAll(names);
return (available);
}
/*******************************************************************************
** Literal replacements avoid treating customer data as a template program.
*******************************************************************************/
@@ -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,10 +33,11 @@ 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;
@@ -47,23 +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;
/*******************************************************************************
** Core health checks with retained legacy HealthIndicator adapters. 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.
*******************************************************************************/
@@ -73,6 +77,7 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Allow a host transport adapter.
*******************************************************************************/
@@ -82,151 +87,141 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Register fixed core definitions without database reads or captured user sessions.
** Legacy indicator names stay unchanged for existing middleware callers.
** Register the CRM checks with the instance's core health registry.
** Production may run repeatedly; an already registered name is kept.
*******************************************************************************/
public static void register(QInstance instance, CrmQBitConfig config)
{
new CrmHealthChecks(config).registerWith(instance);
}
/*******************************************************************************
** Register these checks (with this transport) with the core health registry.
*******************************************************************************/
public void registerWith(QInstance instance)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
CrmHealthChecks checks = new CrmHealthChecks(config);
Map<String, String> names = Map.of("crm.email", "crm.email", "crm.sequence-lag", "crm.sequenceLag", "crm.lead-score", "crm.leadScore");
names.forEach((name, legacyName) ->
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);
}
/*******************************************************************************
** Run all checks once in the caller's context, without registering anything.
*******************************************************************************/
public Map<String, HealthCheckOutcome> run() throws QException
{
Map<String, HealthCheckOutcome> outcomes = new LinkedHashMap<>();
for(String name : List.of(EMAIL, SEQUENCE_LAG, LEAD_SCORE))
{
if(registry.getChecks().stream().noneMatch(check -> name.equals(check.getName())))
outcomes.put(name, check(name).run(new HealthCheckInput()));
}
return (outcomes);
}
/*******************************************************************************
** 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()
{
/*******************************************************************
** Evaluate the named check in the current QContext.
*******************************************************************/
@Override
public HealthCheckOutcome run(HealthCheckInput input)
{
registry.withCheck(new HealthCheckMetaData().withName(name).withLabel(name).withComponent("crm")
.withCategory(HealthCategory.INTEGRATION).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(300).withTimeoutMs(5000).withCheckInstance(input ->
try
{
return (switch(name)
{
if(input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline()))
{
return (HealthCheckOutcome.unknown("CRM check deadline exceeded"));
}
HealthCheckResult result = checks.new Indicator(legacyName, QContext.getQSession()).check(input.getQInstance());
return (new HealthCheckOutcome().withStatus(com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(result.getStatus().name()))
.withMessage("CRM health check " + result.getStatus().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"));
}
}
});
}
/*******************************************************************************
** Run all checks without registering tables or starting background workers.
*******************************************************************************/
public Map<String, HealthCheckResult> run() throws QException
{
return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore()));
}
/*******************************************************************************
** 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.
** Add one check definition unless the name is already registered.
*******************************************************************************/
public List<HealthIndicator> indicators()
private void add(HealthChecksMetaData registry, String name, String label, HealthCategory category, int intervalSeconds)
{
return (indicators(QContext.getQSession()));
}
/*******************************************************************************
** 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.
*******************************************************************************/
private class Indicator implements HealthIndicator
{
private final String name;
private final QSession session;
/*******************************************************************************
** Select a check.
*******************************************************************************/
private Indicator(String name, QSession session)
if(registry.getChecks().stream().anyMatch(existing -> name.equals(existing.getName())))
{
this.name = name;
this.session = session;
}
@Override
public String getName()
{
return (name);
}
@Override
public HealthCheckResult check(QInstance instance)
{
CapturedContext original = QContext.capture();
try
{
QContext.init(instance, session != null ? session : new QSystemUserSession(instance));
return (switch(name)
{
case "crm.email" -> email();
case "crm.sequenceLag" -> sequenceLag();
default -> leadScore();
});
}
catch(QException | RuntimeException e)
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed"));
}
finally
{
QContext.init(original);
}
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"));
}
}
@@ -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();
@@ -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,78 +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);
}
/*******************************************************************************
** Core registration is idempotent, preserves tenant visibility and legacy results.
*******************************************************************************/
@Test
void coreRegistrationPreservesCallerAndWorkerContexts() throws Exception
{
var instance = QContext.getQInstance();
var session = QContext.getQSession();
CrmHealthChecks.register(instance, new CrmQBitConfig());
var registry = com.kingsrook.qqq.backend.core.health.HealthChecksMetaData.ofOrWithNew(instance);
assertThat(registry.getChecks()).extracting(check -> check.getName())
.containsExactlyInAnyOrder("crm.email", "crm.sequence-lag", "crm.lead-score");
var check = registry.getChecks().stream().filter(value -> value.getName().equals("crm.sequence-lag")).findFirst().orElseThrow();
P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 9).withValue("contactId", 2)
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("nextStepDate", Instant.now().minus(2, ChronoUnit.HOURS)));
instance.addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("healthTenant"));
instance.getTable("crm_sequence_enrollment").withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock()
.withFieldName("contactId").withSecurityKeyType("healthTenant"));
session.withSecurityKeyValue("healthTenant", 1);
var input = new com.kingsrook.qqq.backend.core.health.HealthCheckInput().withQInstance(instance);
assertThat(check.getCheckInstance().run(input).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UP);
assertThat(QContext.getQSession()).isSameAs(session);
session.withSecurityKeyValue("healthTenant", 2);
assertThat(check.getCheckInstance().run(input).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.DEGRADED);
assertThat(new CrmHealthChecks(new CrmQBitConfig()).indicators(session).get(1).check(instance).getStatus()).isEqualTo(HealthStatus.DEGRADED);
var worker = java.util.concurrent.CompletableFuture.supplyAsync(() ->
{
try
{
var result = check.getCheckInstance().run(input);
var legacy = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1).check(instance);
assertThat(result.getStatus().name()).isEqualTo(legacy.getStatus().name());
assertThat(QContext.getQInstance()).isNull();
assertThat(QContext.getQSession()).isNull();
return (result.getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
}).get();
// A system session authenticates the worker but does not invent tenant security keys.
assertThat(worker).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UP);
assertThat(check.getCheckInstance().run(input.withDeadline(Instant.EPOCH)).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UNKNOWN);
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
.filter(check -> "crm".equals(check.getComponent())).toList());
}
}
@@ -1,92 +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.
*/
/*******************************************************************************
** Unit tests for CrmQBitProducer.
*******************************************************************************/
package com.kingsrook.qbits.crm;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Migration opt-in removes metadata completely without changing legacy hosts.
*******************************************************************************/
class SharedEmailTemplateMetadataTest extends BaseTest
{
/*******************************************************************************
** Existing hosts keep their actual legacy table, source and configuration.
*******************************************************************************/
@Test
void legacyDefaultRemains()
{
assertThat(new CrmQBitConfig().getSharedEmailTemplates()).isFalse();
assertThat(QContext.getQInstance().getTable("crm_email_template")).isNotNull();
assertThat(QContext.getQInstance().getPossibleValueSource("crm_email_template")).isNotNull();
}
/*******************************************************************************
** The opt-in host has one owner and no retired-table or dummy-source metadata.
*******************************************************************************/
@Test
void sharedOwnerMetadataValidates() throws Exception
{
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withTemplatePermissionBaseName("crm.manage")).produce(instance);
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables("memory").withSharedEmailTemplates(true))
.produce(instance).addSelfToInstance(instance);
new QInstanceValidator().validate(instance);
assertThat(instance.getTable("crm_email_template")).isNull();
assertThat(instance.getPossibleValueSource("crm_email_template")).isNull();
assertThat(instance.getTable("email_template")).isNotNull();
assertThat(instance.getTable("crm_sequence_step").getField("emailTemplateId").getPossibleValueSourceName()).isEqualTo("email_template");
assertThat(instance.getTable("crm_email_message").getField("emailTemplateId").getPossibleValueSourceName()).isEqualTo("email_template");
assertThat(instance.getProcess("crm_send_email").getInputField("emailTemplateId").orElseThrow().getPossibleValueSourceName()).isEqualTo("email_template");
assertThat(instance.getApp("crm").getSections()).allSatisfy(section -> assertThat(section.getTables()).doesNotContain("crm_email_template"));
}
/*******************************************************************************
** Unsupported tenant opt-in fails before changing existing metadata.
*******************************************************************************/
@Test
void optInRejectsMissingOwnerAndTenancy()
{
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withSharedEmailTemplates(true))
.produce(new QInstance())).hasMessageContaining("installed qbit-email");
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withSharedEmailTemplates(true).withTenantSecurityKeyType("tenant"))
.produce(new QInstance())).hasMessageContaining("non-tenant");
}
}
@@ -162,32 +162,6 @@ class CrmActionsTest extends BaseTest
assertThat(QContext.getQInstance().getProcess("crm_move_deal_stage").getInputField("targetStageId").orElseThrow().getPossibleValueSourceFilter()).isNotNull();
}
/*******************************************************************************
** Runtime Review uses the shared owner even though action field prototypes are legacy.
*******************************************************************************/
@Test
void sharedTemplateReviewDoesNotReadRetiredTable() throws Exception
{
seed(CrmActions.action("crm_send_email"));
var instance = QContext.getQInstance();
new com.kingsrook.qbits.email.EmailQBitProducer().withQBitConfig(new com.kingsrook.qbits.email.EmailQBitConfig()
.withBackendName(BACKEND_NAME).withTemplatePermissionBaseName("crm.manage")).produce(instance);
new com.kingsrook.qqq.backend.core.instances.QInstanceValidator().validate(instance);
var grants = new java.util.HashSet<>(CrmPermissions.ALL);
grants.addAll(com.kingsrook.qbits.email.permissions.EmailPermissions.ALL);
QContext.getQSession().setPermissions(grants);
com.kingsrook.qbits.crm.api.CrmApi.config().withSharedEmailTemplates(true);
com.kingsrook.qbits.email.api.EmailApi.ensureTemplate(new com.kingsrook.qbits.email.api.EmailApiTemplate().withName("shared_review").withLabel("Shared")
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(com.kingsrook.qbits.email.api.EmailApiCategory.TRANSACTIONAL).withSubject("Hi").withBody("Body"));
Integer template = rows("email_template").getFirst().getValueInteger("id");
instance.getTables().remove("crm_email_template");
instance.getProcess("crm_send_email").getInputField("emailTemplateId").orElseThrow().setPossibleValueSourceName("email_template");
var values = inputs();
values.put("emailTemplateId", template);
var result = review("crm_send_email", values, List.of(1));
assertThat(lines(result, false)).isNotEmpty().allMatch(line -> line.getStatus() != Status.ERROR);
}
/*******************************************************************************
** Administrative and management actions have distinct permission grants.
*******************************************************************************/
@@ -20,28 +20,13 @@
package com.kingsrook.qbits.crm.email;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
@@ -74,33 +59,6 @@ public class CrmEmailTransportTest extends BaseTest
RecordingProvider.last = null;
}
/*******************************************************************************
** Shared mode selects the email-owned template and records the final subject.
*******************************************************************************/
@Test
void sharedTemplateUsesOwnerAndExactSubject() throws Exception
{
var instance = QContext.getQInstance();
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig()
.withBackendName(BACKEND_NAME).withTemplatePermissionBaseName("crm.manage")).produce(instance);
new QInstanceValidator().validate(instance);
Set<String> permissions = new HashSet<>(CrmPermissions.ALL);
permissions.addAll(EmailPermissions.ALL);
QContext.getQSession().setPermissions(permissions);
new InsertAction().execute(new InsertInput("email_provider")
.withRecord(new QRecord().withValue("name", "test").withValue("label", "Test").withValue("kind", "SMTP")
.withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE").withValue("fromAddress", "sender@example.test").withValue("enabled", true).withValue("isDefault", true)));
EmailApi.ensureTemplate(new EmailApiTemplate().withName("shared")
.withLabel("Shared").withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("Message {{message.id}}").withBody("<p>Native HTML</p>"));
var templates = new QueryAction().execute(new QueryInput("email_template")).getRecords();
var transport = new CrmEmailTransport(CrmApi.config().withSharedEmailTemplates(true));
var first = transport.sendTemplateMessage("manual", "42", 1, "recipient@example.test", templates.getFirst().getValueInteger("id"), null, null, false, Map.of());
assertThat(first.subject()).isEqualTo("Message " + first.delivery().messageId());
var repeated = transport.sendTemplateMessage("manual", "42", 1, "recipient@example.test", templates.getFirst().getValueInteger("id"), null, null, false, Map.of());
assertThat(repeated).isEqualTo(first);
}
/*******************************************************************************
** Public constructor allows the core dispatcher to instantiate this provider.
*******************************************************************************/