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
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
james.maes a025eeee2f Merge pull request #16 from QRun-IO/feature/health-system-session
fix(health): run context-less health checks as the system user
2026-10-14 18:27:16 -05:00
james.maesandClaude Opus 5.5 80bf85a158 fix(health): run context-less health checks as the system user
CrmHealthChecks.indicators() captured a plain tokenless new QSession()
when registered without a caller session (host startup). Token-based
authentication modules (OAuth2, Auth0) reject any tokenless session other
than QSystemUserSession, so on such hosts every CRM check failed session
validation and reported UNKNOWN.

With no caller session, each check now runs as core
QSystemUserSession(instance), created at check time, the same pattern as
qbit-backups#24. A supplied monitoring session is still used unchanged.
The checks return only UP/DEGRADED, never values.

contextlessIndicatorsRunOnTokenRequiredHost uses a test authentication
module that rejects tokenless sessions; it fails without this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:24:59 -05:00
james.maes 24707575fc Merge pull request #15 from QRun-IO/feature/tables-populated-defaults
Populate and explain every CRM table
2026-10-14 17:10:40 -05:00
james.maesandClaude Opus 5.5 d6900e3842 test: commit host rows before the Liquibase rollback in the rollback test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:07:40 -05:00
james.maesandClaude Opus 5.5 dc4ad764f3 fix(defaults): empty rollbacks, fresh-install guards, ordering docs, behavior test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:05:08 -05:00
james.maesandClaude Opus 5.5 4b4b33127e feat: populate and explain every CRM table (defaults changelog, setup card, help, currency date)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:51:18 -05:00
james.maes c8d765a135 Merge pull request #14 from QRun-IO/feature/ui-polish
fix(ui): labels, money formatting, contact layout and home polish
2026-10-14 15:42:34 -05:00
james.maesandClaude Opus 5.5 2babcaf3b4 fix(ui): plain table and field labels, contact layout, 4-column child lists, nullable names and owners, close dates on closed-stage inserts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:39:27 -05:00
james.maesandClaude Opus 5.5 31964ff0e0 fix(home): grouped currency amounts, Stage/Deals/Weighted columns, compact Today statistics, readable timeline dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:39:26 -05:00
james.maes 50acf63211 Merge pull request #13 from QRun-IO/feature/pii-tenancy-api
feat: PII, tenancy, search/API, help bundle, health, full GDPR erase, bulk-load profiles
2026-10-14 15:00:14 -05:00
james.maesandClaude Opus 5.5 1f994e127b fix(schema): 320-char emails, 500-char UTM fields; cap lead source names from UTM at 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:36 -05:00
james.maesandClaude Opus 5.5 212c7aaa77 fix(home): let Next size home table columns (no fixed widths)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:35 -05:00
james.maesandClaude Opus 5.5 40a93c3286 fix(app): produce a plain QAppMetaData so hosts can attach it as a child app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:35 -05:00
james.maesandClaude Opus 5.5 da0ba96966 feat: PII classification, optional tenancy locks, search fields and API publication
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:55 -05:00
james.maesandClaude Opus 5.5 b7b146259f feat: contingent primary contact and server-side stage and outcome validation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:54 -05:00
james.maesandClaude Opus 5.5 7ee43485c3 feat(privacy): Erase Personal Data covers every CRM table and core audit copies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:54 -05:00
james.maesandClaude Opus 5.5 019d93a5d7 feat: CRM health checks (stand-in qqq#1199) and bulk-load starter profiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:53 -05:00
james.maesandClaude Opus 5.5 99102d3175 feat(help): qbit-crm help bundle with stand-in loader (qqq#1198)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:52 -05:00
james.maes 90cdaaa88d Merge pull request #12 from QRun-IO/feature/platform-reuse
feat: real email and sequences, delivery sync, schedules, notifications, collaboration features
2026-10-14 13:34:57 -05:00
james.maes 424f6ea379 Merge remote-tracking branch 'origin/develop' into feature/platform-reuse 2026-10-14 13:32:17 -05:00
james.maesandClaude Opus 5.5 8d9dfb9172 feat: schedules, collaboration record features and alert types
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:24 -05:00
james.maesandClaude Opus 5.5 e1fb2c0deb feat(deals): won and rotting notifications through QAlerts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:24 -05:00
james.maesandClaude Opus 5.5 68220ec915 feat(scoring): date-decay operators, incremental recalculation and auto-assign on insert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:23 -05:00
james.maesandClaude Opus 5.5 666cec834e feat(email): Send Email and sequence steps send through CrmEmailTransport; delivery sync and consent expiry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:22 -05:00
james.maes 4b1d272ac5 Merge pull request #11 from QRun-IO/feature/record-actions
feat(actions): CRM record actions on the standard streamed ETL
2026-10-14 13:06:13 -05:00
james.maes 06d41fb3f7 Merge remote-tracking branch 'origin/develop' into feature/record-actions
# Conflicts:
#	src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java
2026-10-14 13:03:32 -05:00
james.maesandClaude Opus 5.5 a93365a7b7 refactor: retire reminder, overdue, import, track and old bulk processes; page background jobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:00:39 -05:00
james.maesandClaude Opus 5.5 15a2549b59 feat(actions): CRM record actions on the standard streamed ETL with review and result summaries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:00:38 -05:00
james.maes 831bc59743 Merge pull request #10 from QRun-IO/feature/app-home
feat(app): CRM app, real home dashboard and child lists
2026-10-14 12:52:45 -05:00
james.maes 7a9a38afe4 Merge remote-tracking branch 'origin/develop' into feature/app-home
# Conflicts:
#	src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java
#	src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java
#	src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java
2026-10-14 12:50:29 -05:00
james.maes 45df8cd380 Merge pull request #9 from QRun-IO/feature/intake-api
feat(api): CRM intake and marketing contracts, double opt-in, signed hooks
2026-10-14 12:48:48 -05:00
james.maes 43c6fe83b1 Merge remote-tracking branch 'origin/develop' into feature/app-home 2026-10-14 12:46:04 -05:00
james.maesandClaude Opus 5.5 8381850999 feat: child lists and joins on contact, company, deal, pipeline, sequence, list, rule and activity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:38:14 -05:00
james.maesandClaude Opus 5.5 85b83c8031 feat(home): real-data CRM home; retire trend widgets for data analysis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:38:13 -05:00
james.maesandClaude Opus 5.5 fc92690573 feat(app): one crm app with main and settings sections; processes never in nav
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:38:12 -05:00
177 changed files with 12528 additions and 8831 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
+13
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.
@@ -10,3 +18,8 @@
- Apply CRM, management and admin permission rules and source-QBit metadata, including an RC.6 widget provenance adapter.
- Ship PostgreSQL DDL, 33 Liquibase changesets, and H2 persistence and schema tests. 0.2.0 is a fresh install; there is no upgrade path from 0.1.0.
- Correct installation instructions and document the existing P1 behavior and remaining limitations.
- Add `crm-defaults.changelog.sql`: insert-only starter data (Sales Pipeline and stages, lifecycle stages, lead sources, industries, win/loss reasons, contact roles, activity types and outcomes, USD base currency). See UPGRADE.md.
- Add a Setup card to the CRM home (default pipeline, stages, lifecycle stages, base currency, email delivery).
- Every table help now says how its rows get created; `crm_external_mapping` is documented as not written by the CRM yet.
- Stamp `crm_currency.rateLastUpdatedDate` when a rate is set or changed; the field is now read-only.
- Add `CrmTablesGuardrailTest` and a Liquibase-driven defaults test (liquibase-core as a test dependency).
+99 -40
View File
@@ -1,6 +1,6 @@
# QBit CRM
**0.2.0-SNAPSHOT — unreleased, P1 Platform.** CRM tables, processes and widgets for QQQ applications. This phase upgrades the build, names, permissions, audit integration and database schema. It does not complete the later 0.2.0 product phases.
**0.2.0-SNAPSHOT — unreleased, P1 Platform + P2 App, home and nav.** CRM tables, processes and widgets for QQQ applications. This work upgrades the build, names, permissions, audit integration and database schema, and supplies the CRM app and record navigation. It does not complete the later 0.2.0 product phases.
## Build and verification
@@ -50,14 +50,21 @@ new CrmQBitProducer()
qInstance.setEnforcePermissionsInCoreActions(true);
```
4. Map the host's authenticated roles to the permissions below. Configure the host's core audit storage. Add tables, processes and widgets to the host UI as needed: `produceAppSection()` currently returns an empty CRM section; P1 does not provide an assembled app or home dashboard.
4. Map the host's authenticated roles to the permissions below. Configure the host's core audit storage. The `crm` app is enabled by default, with main and Settings navigation and a real-data home. Set `withAppEnabled(false)` to embed the tables in the host's own app. `produceAppSection()` returns the same populated main section; processes are record/table actions, never navigation entries.
## Configuration and access
- `defaultBackendNameForTables`: required backend name.
- `inferBackendNames`: defaults to true; sets snake_case SQL column names while Java/QQQ field names remain camelCase. SQL table names equal the QQQ table names. Set false only for an explicit host column mapping.
- `tableMetaDataCustomizer`: optional host table customizer.
- `securityFields` and `recordSecurityLocks`: retained in P1, applied to contact, company and deal. Host-managed schema columns must match these fields. This is not yet tenant isolation across every child table or workflow.
- `withTenantSecurityKeyType("tenant")`: enables nullable `tenantKey` fields and record locks. It defaults to null (off). `withTenantAllAccessKeyName("allTenants")` names the host's all-access key. Existing host key types are preserved.
- Tenant-enabled hosts separately include `src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql` **after** the base changelog and explicitly select the `crm-tenancy` context. Single-tenant hosts omit that file. Child records use parent join locks; polymorphic external mappings and internal job checkpoints carry direct tenant keys. Hosts must supply tenant keys on new direct-key records.
- `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.
- 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.
Help and health both use the core registries (`HelpBundles`, `HealthChecksMetaData`). See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
| Scope | Permissions |
|---|---|
@@ -68,11 +75,21 @@ qInstance.setEnforcePermissionsInCoreActions(true);
| Merge, bulk assignment, list and sequence management processes | `crm.manage.hasAccess` |
| GDPR anonymization | `crm.admin.hasAccess` |
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The widget provenance adapter supplies core's `SourceQBitAware` interface because RC.6's widget class lacks it.
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The app and widget provenance adapters supply core's `SourceQBitAware` interface because RC.6's app and widget classes lack it.
## Starter defaults
Include `db/liquibase/qbit-crm/crm-defaults.changelog.sql` in the host changelog after `crm.changelog.sql` and, on a tenant-enabled install, after `crm-tenancy.changelog.sql` as well:
```yaml
- include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
```
It seeds, insert-only: the activity types and call/meeting outcomes; the default `Sales Pipeline` (Qualification 10%, Discovery 25%, Proposal 50%, Negotiation 75%, Closed Won 100%, Closed Lost 0%); lifecycle stages (the code looks up `Lead` and `Opportunity` by name); lead sources (`Website` is the web-form default); industries; win and loss reasons (prefixed `Won:` / `Lost:` because names are unique); contact roles; and a USD base currency. Lists are seeded only while empty, so a configured host is unchanged. The lifecycle stages (they change what intake and Convert lead assign) and the base currency are seeded only on a fresh install (no contact, respectively no deal, yet), and the stages only when `Sales Pipeline` is the only pipeline. Rollbacks are empty, so they never delete host rows. Tenant-enabled hosts seed per tenant (the changelog skips tenant-keyed tables). The Setup card on the CRM home shows what is still missing.
## Tables
All 33 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata.
All 34 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata.
### Main tables (6)
@@ -90,52 +107,94 @@ Contact, company, deal and activity declare core FIELD-level audit rules. The be
## Processes
The QBit produces 25 processes with these names and labels. QQQ may additionally generate standard table actions such as `<table>.bulkInsert`; those retain core's naming conventions. CRM processes are available to host wiring and are not registered as scheduled jobs.
The QBit produces 28 table-bound record actions. Every action uses the standard
streamed ETL Review and Result screens with linked summary lines. Required inputs
are collected before Review. Processes never appear in app navigation. Find
Duplicates scans the selected records (select all matching records to scan the
table); each duplicate group links to its members. Merges require exactly two
selected records and a choice of which record to keep.
| Process name | Label |
|---|---|
| `crm_auto_assign` | Auto Assign |
| `crm_bulk_assign_owner` | Bulk Assign Owner |
| `crm_calculate_lead_scores` | Calculate Lead Scores |
| `crm_clone_deal` | Clone Deal |
| `crm_convert_form_to_contact` | Convert Form to Contact |
| `crm_convert_lead` | Convert Lead |
| `crm_detect_duplicates` | Detect Duplicates |
| `crm_enroll_in_sequence` | Enroll in Sequence |
| `crm_fire_reminders` | Fire Reminders |
| `crm_gdpr_anonymize_contact` | GDPR Anonymize Contact |
| `crm_import_contacts` | Import Contacts |
| `crm_log_activity` | Log Activity |
| `crm_manage_contact_on_deal` | Manage Contact on Deal |
| `crm_merge_companies` | Merge Companies |
| `crm_merge_contacts` | Merge Contacts |
| `crm_move_deal_stage` | Move Deal Stage |
| `crm_process_overdue_tasks` | Process Overdue Tasks |
| `crm_process_sequence_steps` | Process Sequence Steps |
| `crm_reopen_deal` | Reopen Deal |
| `crm_send_email` | Send Email |
| `crm_subscribe_to_list` | Subscribe to List |
| `crm_track_email_event` | Track Email Event |
| `crm_transfer_ownership` | Transfer Ownership |
| `crm_unenroll_from_sequence` | Unenroll from Sequence |
| `crm_unsubscribe_from_list` | Unsubscribe from List |
| Table | Process name | Label |
|---|---|---|
| `crm_contact` | `crm_bulk_assign_owner` | Assign Owner |
| `crm_contact` | `crm_auto_assign` | Auto-Assign |
| `crm_contact` | `crm_subscribe_to_list` | Add to List |
| `crm_contact` | `crm_unsubscribe_from_list` | Remove from List |
| `crm_contact` | `crm_enroll_in_sequence` | Enroll in Sequence |
| `crm_contact` | `crm_unenroll_from_sequence` | Unenroll from Sequence |
| `crm_contact` | `crm_log_activity` | Log Activity |
| `crm_contact` | `crm_recalculate_score` | Recalculate Score |
| `crm_contact` | `crm_merge_contacts` | Merge Contacts |
| `crm_contact` | `crm_detect_duplicates` | Find Duplicates |
| `crm_contact` | `crm_convert_lead` | Create Deal |
| `crm_contact` | `crm_gdpr_anonymize_contact` | Erase Personal Data |
| `crm_contact` | `crm_send_email` | Send Email |
| `crm_company` | `crm_assign_company_owner` | Assign Owner |
| `crm_company` | `crm_merge_companies` | Merge Companies |
| `crm_company` | `crm_find_duplicate_companies` | Find Duplicates |
| `crm_company` | `crm_log_company_activity` | Log Activity |
| `crm_deal` | `crm_move_deal_stage` | Move Stage |
| `crm_deal` | `crm_mark_deal_won` | Mark Won |
| `crm_deal` | `crm_mark_deal_lost` | Mark Lost |
| `crm_deal` | `crm_reopen_deal` | Reopen |
| `crm_deal` | `crm_clone_deal` | Clone |
| `crm_deal` | `crm_transfer_ownership` | Assign Owner |
| `crm_deal` | `crm_manage_contact_on_deal` | Add Contact |
| `crm_deal` | `crm_log_deal_activity` | Log Activity |
| `crm_form_submission` | `crm_convert_form_to_contact` | Convert to Contact |
| `crm_email_sequence` | `crm_activate_sequence` | Activate |
| `crm_email_sequence` | `crm_pause_sequence` | Pause |
Move Stage and Reopen choose a stage from the selected deals' pipeline. Select
one pipeline at a time for those actions. Mark Won and Mark Lost require a
win/loss reason and resolve the terminal stage within each deal's pipeline.
Two hidden background processes have no table binding: `crm_process_sequence_steps`
and `crm_calculate_lead_scores`. Both use id keyset pages of 200. They have no
shipped schedules yet; the host can invoke them. QQQ also generates standard
bulk-load and bulk-edit actions such as `<table>.bulkInsert`.
Fire Reminders and Process Overdue Tasks were removed in favor of collaboration
tasks and alerts. Import Contacts was removed in favor of core bulk load. Track
Email Event was removed in favor of the later delivery sync.
## Current behavior and limits
The existing pipeline/stage logic, stage history, product totals, currency conversion, contact/company merge, activity logging, form auto-linking, list subscribe/unsubscribe, scoring and assignment processes remain. Fourteen custom dashboard widget producers remain available for host placement, together with generated child-record widgets.
The existing pipeline/stage logic, stage history, product totals, currency conversion, contact/company merge, activity logging, form auto-linking, list subscribe/unsubscribe, scoring and assignment processes remain. The CRM home contains Today (five standard statistics), Pipeline by stage, Forecast (current month plus five), Needs attention (ten oldest rotting deals), Recent web leads (latest ten), Goals (up to fifty current goals, hidden when none are visible), and Leaderboard (quarter-to-date won revenue by owner). Reporting boundaries use UTC. Monetary summaries use base currency, with weighted amounts grouped by currency before conversion. Aggregate rows link to their underlying records; stage name and deal count share a column to keep tables to three columns. The pipeline picker appears when multiple active pipelines are visible.
Some process names describe unfinished behavior inherited from 0.1.0:
The contact Timeline shows the latest fifty recorded events from activities, email messages, form submissions and consent, newest first by creation date. Native child lists connect all specified parent screens. Deal primary-contact and email-message contact references are included in the fresh-install schema. The trend widgets, redundant recent-deals/activity-count widgets and activity feed have been retired.
- Send Email and sequence email steps record CRM activity/message state; they do not deliver email. Template merging and live delivery tracking are not implemented here.
- Sequence execution, lead scoring and overdue-task work require host invocation. No schedules are shipped. Assignment is a process, not an automatic insert hook; reminders do not deliver notifications.
- Scoring's date-decay operators remain unimplemented. Import Contacts expects pre-parsed input and does not provide the planned streamed bulk-load workflow.
Later phases complete the following behavior:
- Send Email is a non-sending action shell. It checks for `CrmEmailTransport` with `Class.forName` and reports **Email sending arrives with the email transport** when absent. It never writes a fake sent message or activity. If the class is present, the shell reports that action integration is still pending. Sequence email steps retain the existing activity-only behavior until transport integration.
- Sequence execution and lead scoring require host invocation. No schedules are shipped. Assignment remains an action; automatic insert hooks, collaboration and alert integration arrive later.
- Activate/Pause update the sequence active flag. The existing sequence runner's business logic is preserved in P2B and does not yet consult that flag; enforcing it during execution is a follow-up.
- Scoring's date-decay operators remain unimplemented. Core bulk load is the import path; saved CRM import profiles arrive later.
- GDPR anonymization scrubs the contact, unenrolls sequences and records withdrawn consent. It does not yet erase all linked PII or core audit details. The P1 schema retains existing PII fields; classification, complete erasure and redesigned tenancy are later work.
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and full app navigation are not part of this phase.
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. `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/`.
`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/`.
Licensed under [Apache License 2.0](LICENSE).
+24
View File
@@ -0,0 +1,24 @@
# Upgrading
## 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,
converting a lead fails with "no default pipeline found", new web leads and converted contacts silently get no
lifecycle stage, and amounts have no currency prefix.
What a host must do:
1. Add one line to its master changelog, after `crm.changelog.sql` and, on tenant-enabled installs, after `crm-tenancy.changelog.sql` (the file checks for `tenant_key` columns, so the order matters):
```yaml
- include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
```
2. Review what it seeds (see README, "Starter defaults"). Existing configuration is never touched: lists are seeded
only while their table is empty, the pipeline only when no pipeline exists, its stages only when it is the only pipeline and no stage or deal exists. Lifecycle stages and the base currency are seeded only on a fresh install (no contact, respectively no deal, yet), because they change what intake and Convert lead assign. Rollbacks are empty.
3. Tenant-enabled hosts (the `crm-tenancy` changelog added `tenant_key` columns): the changelog skips those tables
(marks them ran), because rows without a tenant key are invisible to tenants. Insert the same rows once per tenant
with `tenant_key` set. The Setup card on the CRM home shows what is still missing.
4. `crm_currency.rate_last_updated_date` is now written by the CRM when a rate is set or changed and is read-only in
the UI. Existing values are kept.
5. No schema change. `crm_external_mapping` is documented as not written by the CRM yet.
+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
+43 -26
View File
@@ -1,11 +1,12 @@
-- Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0.
-- https://www.apache.org/licenses/LICENSE-2.0
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (33 tables).
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (34 tables).
-- QQQ table names equal SQL names; Java field names map to snake_case columns.
-- Unique constraints match QQQ metadata. Relationship columns are indexed;
-- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns
-- validation and deletion policy, including polymorphic and optional references).
-- Optional host-configured securityFields require matching host-managed columns.
-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy.
-- With tenancy off, do not include it; this base schema has no tenant_key columns.
CREATE TABLE IF NOT EXISTS crm_activity (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -161,27 +162,27 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_record (contact_id);
CREATE TABLE IF NOT EXISTS crm_contact (
first_touch_utm_source VARCHAR(250),
first_touch_utm_medium VARCHAR(250),
first_touch_utm_campaign VARCHAR(250),
first_touch_utm_content VARCHAR(250),
first_touch_utm_term VARCHAR(250),
first_touch_utm_source VARCHAR(500),
first_touch_utm_medium VARCHAR(500),
first_touch_utm_campaign VARCHAR(500),
first_touch_utm_content VARCHAR(500),
first_touch_utm_term VARCHAR(500),
first_touch_landing_page VARCHAR(1000),
first_touch_referrer VARCHAR(1000),
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
last_touch_utm_source VARCHAR(250),
last_touch_utm_medium VARCHAR(250),
last_touch_utm_campaign VARCHAR(250),
last_touch_utm_content VARCHAR(250),
last_touch_utm_term VARCHAR(250),
last_touch_utm_source VARCHAR(500),
last_touch_utm_medium VARCHAR(500),
last_touch_utm_campaign VARCHAR(500),
last_touch_utm_content VARCHAR(500),
last_touch_utm_term VARCHAR(500),
last_touch_landing_page VARCHAR(1000),
last_touch_referrer VARCHAR(1000),
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(250),
secondary_email VARCHAR(250),
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(320),
secondary_email VARCHAR(320),
phone VARCHAR(50),
mobile_phone VARCHAR(50),
job_title VARCHAR(150),
@@ -189,7 +190,7 @@ CREATE TABLE IF NOT EXISTS crm_contact (
company_id INTEGER,
lifecycle_stage_id INTEGER,
lead_source_id INTEGER,
owner_user_id TEXT NOT NULL,
owner_user_id TEXT,
address_line1 VARCHAR(250),
address_line2 VARCHAR(250),
city VARCHAR(100),
@@ -251,7 +252,8 @@ CREATE TABLE IF NOT EXISTS crm_deal (
probability_override_pct INTEGER,
weighted_amount DECIMAL(20,6),
company_id INTEGER,
owner_user_id TEXT NOT NULL,
primary_contact_id INTEGER,
owner_user_id TEXT,
lead_source_id INTEGER,
expected_close_date DATE,
actual_close_date DATE,
@@ -266,6 +268,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_id ON crm_deal (pipeline_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_stage_id ON crm_deal (pipeline_stage_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_primary_contact_id ON crm_deal (primary_contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_company_id ON crm_deal (company_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_owner_user_id ON crm_deal (owner_user_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_lead_source_id ON crm_deal (lead_source_id);
@@ -344,7 +347,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email_list_id INTEGER NOT NULL,
contact_id INTEGER,
email VARCHAR(250) NOT NULL,
email VARCHAR(320) NOT NULL,
first_name VARCHAR(100),
status INTEGER DEFAULT 1 NOT NULL,
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
@@ -362,8 +365,13 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
CREATE TABLE IF NOT EXISTS crm_email_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
idempotency_key VARCHAR(250) UNIQUE,
email_message_id VARCHAR(100),
delivered_date TIMESTAMP WITHOUT TIME ZONE,
bounced_date TIMESTAMP WITHOUT TIME ZONE,
activity_id INTEGER,
from_address VARCHAR(250) NOT NULL,
contact_id INTEGER,
from_address VARCHAR(250),
from_name VARCHAR(100),
to_addresses TEXT NOT NULL,
cc_addresses TEXT,
@@ -389,6 +397,8 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
modify_date TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_message_id ON crm_email_message (email_message_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_template_id ON crm_email_message (email_template_id);
@@ -451,18 +461,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
client_hash VARCHAR(64),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
form_type INTEGER NOT NULL,
email VARCHAR(250) NOT NULL,
email VARCHAR(320) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
company VARCHAR(250),
phone VARCHAR(50),
message TEXT,
source_url VARCHAR(1000),
utm_source VARCHAR(250),
utm_medium VARCHAR(250),
utm_campaign VARCHAR(250),
utm_term VARCHAR(250),
utm_content VARCHAR(250),
utm_source VARCHAR(500),
utm_medium VARCHAR(500),
utm_campaign VARCHAR(500),
utm_term VARCHAR(500),
utm_content VARCHAR(500),
referrer VARCHAR(1000),
contact_id INTEGER,
conversion_status INTEGER DEFAULT 1 NOT NULL,
@@ -636,3 +646,10 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
);
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
CREATE TABLE IF NOT EXISTS crm_job_state (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(250) NOT NULL UNIQUE,
last_success TIMESTAMP WITHOUT TIME ZONE,
last_full_pass TIMESTAMP WITHOUT TIME ZONE
);
+46 -4
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>
@@ -73,10 +74,16 @@
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-api</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>
@@ -102,6 +109,13 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -117,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>
@@ -186,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>
@@ -0,0 +1,84 @@
/*
* 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;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qbits.crm.widgets.CrmHomeWidgets;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
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.tables.QTableMetaData;
/*******************************************************************************
** The CRM home and the two navigation sections. Child tables stay on records.
*******************************************************************************/
public final class CrmAppMetaData
{
public static final List<String> MAIN_TABLES = List.of("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list");
public static final List<String> SETTINGS_TABLES = List.of("crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
"crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_currency", "crm_lifecycle_stage", "crm_lead_source",
"crm_industry", "crm_activity_type", "crm_activity_outcome", "crm_contact_role", "crm_win_loss_reason");
/*******************************************************************************
** Metadata factory only.
*******************************************************************************/
private CrmAppMetaData()
{
}
/*******************************************************************************
** Main navigation, also available to hosts embedding these tables.
*******************************************************************************/
public static QAppSection mainSection()
{
return (new QAppSection().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
.withTables(new ArrayList<>(MAIN_TABLES)));
}
/*******************************************************************************
** Construct the app after package discovery has produced the tables.
*******************************************************************************/
public static QAppMetaData build(MetaDataProducerMultiOutput output, String source)
{
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_TABLES)));
List<String> tables = new ArrayList<>(MAIN_TABLES);
tables.addAll(SETTINGS_TABLES);
for(String name : tables)
{
app.withChild(output.get(QTableMetaData.class, name));
}
return (app);
}
}
@@ -0,0 +1,87 @@
/*
* 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;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData;
import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
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.tables.QTableMetaData;
/*******************************************************************************
** Optional host API publication and the core record-search contract.
*******************************************************************************/
public final class CrmIntegrations
{
private static final Map<String, List<String>> SEARCH_FIELDS = Map.of(
"crm_contact", List.of("firstName", "lastName", "email"),
"crm_company", List.of("name", "domain"), "crm_deal", List.of("name"),
"crm_activity", List.of("subject"), "crm_form_submission", List.of("email"), "crm_email_list", List.of("name"));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmIntegrations()
{
}
/*******************************************************************************
** Enable publication only when the host supplies both API settings.
*******************************************************************************/
public static void apply(MetaDataProducerMultiOutput output, CrmQBitConfig config) throws QException
{
if(config.getEsbDestinationName() != null)
{
throw (new QException("CRM ESB publication requires classified-value exclusion, which QQQ 4.1.0-RC.6 does not provide."));
}
boolean api = config.getApiName() != null && config.getApiVersion() != null;
if(api)
{
try
{
Class.forName("com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer");
}
catch(ClassNotFoundException | LinkageError e)
{
throw (new QException("CRM API publication requires qqq-middleware-api on the host classpath.", e));
}
}
for(QTableMetaData table : output.getEach(QTableMetaData.class))
{
if(SEARCH_FIELDS.containsKey(table.getName()))
{
table.withSearchFields(SEARCH_FIELDS.get(table.getName()));
}
if(api && CrmDataPolicy.isPublishedTable(table.getName()))
{
ApiTableMetaDataContainer.ofOrWithNew(table).withApiTableMetaData(config.getApiName(),
new ApiTableMetaData().withInitialVersion(config.getApiVersion()).withIsExcluded(false));
}
}
}
}
@@ -0,0 +1,131 @@
/*
* 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;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** CRM presentation shared by package-discovered tables and child widgets.
*******************************************************************************/
public final class CrmPresentation
{
private static final Map<String, String> LABELS = Map.ofEntries(
Map.entry("crm_contact", "Contacts"),
Map.entry("crm_company", "Companies"),
Map.entry("crm_deal", "Deals"),
Map.entry("crm_activity", "Activities"),
Map.entry("crm_form_submission", "Web Leads"),
Map.entry("crm_email_list", "Lists"),
Map.entry("crm_pipeline", "Pipelines"),
Map.entry("crm_pipeline_stage", "Pipeline Stages"),
Map.entry("crm_product", "Products"),
Map.entry("crm_email_template", "Email Templates"),
Map.entry("crm_email_sequence", "Email Sequences"),
Map.entry("crm_sequence_step", "Sequence Steps"),
Map.entry("crm_sequence_enrollment", "Sequence Enrollments"),
Map.entry("crm_lead_score_rule", "Lead Score Rules"),
Map.entry("crm_assignment_rule", "Assignment Rules"),
Map.entry("crm_assignment_rule_member", "Assignment Rule Members"),
Map.entry("crm_sales_goal", "Sales Goals"),
Map.entry("crm_currency", "Currencies"),
Map.entry("crm_lifecycle_stage", "Lifecycle Stages"),
Map.entry("crm_lead_source", "Lead Sources"),
Map.entry("crm_industry", "Industries"),
Map.entry("crm_activity_type", "Activity Types"),
Map.entry("crm_activity_outcome", "Activity Outcomes"),
Map.entry("crm_contact_role", "Contact Roles"),
Map.entry("crm_win_loss_reason", "Win/Loss Reasons"),
Map.entry("crm_consent_record", "Consent Records"),
Map.entry("crm_email_list_member", "List Members"),
Map.entry("crm_email_message", "Email Messages"),
Map.entry("crm_deal_contact", "Deal Contacts"),
Map.entry("crm_deal_product", "Deal Products"),
Map.entry("crm_deal_stage_history", "Deal Stage History"),
Map.entry("crm_activity_participant", "Activity Participants"),
Map.entry("crm_external_mapping", "External Mappings"),
Map.entry("crm_job_state", "Job State"));
private static final Map<String, List<String>> CHILD_FIELDS = Map.ofEntries(
Map.entry("crm_contact", List.of("firstName", "lastName", "email", "phone")),
Map.entry("crm_deal", List.of("name", "pipelineStageId", "amount", "expectedCloseDate")),
Map.entry("crm_activity", List.of("subject", "activityTypeId", "startDate")),
Map.entry("crm_form_submission", List.of("formKey", "createDate", "conversionStatus")),
Map.entry("crm_email_list_member", List.of("emailListId", "status")),
Map.entry("crm_consent_record", List.of("consentType", "status", "consentDate")),
Map.entry("crm_sequence_enrollment", List.of("sequenceId", "status", "enrolledDate")),
Map.entry("crm_email_message", List.of("subject", "status", "sentDate")),
Map.entry("crm_deal_contact", List.of("contactId", "contactRoleId", "isPrimary")),
Map.entry("crm_deal_product", List.of("productId", "quantity", "unitPrice", "totalAmount")),
Map.entry("crm_deal_stage_history", List.of("fromStageId", "toStageId", "transitionDate", "userId")));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmPresentation()
{
}
/*******************************************************************************
** Explicit labels avoid exposing technical table and user-id names.
*******************************************************************************/
public static void configureTable(QTableMetaData table)
{
table.setLabel(LABELS.get(table.getName()));
Map<String, String> users = Map.of("ownerUserId", "Owner", "assignedToUserId", "Assigned To",
"assignToUserId", "Assign To", "enrolledByUserId", "Enrolled By", "convertedByUserId", "Converted By", "userId", "User");
table.getFields().values().forEach(field ->
{
if(users.containsKey(field.getName()))
{
field.setLabel(users.get(field.getName()));
}
});
Map<String, List<String>> money = Map.of("crm_deal", List.of("amount", "amountInBaseCurrency", "weightedAmount"),
"crm_deal_product", List.of("unitPrice", "totalAmount"), "crm_product", List.of("unitPrice"), "crm_sales_goal", List.of("targetAmount"));
for(String field : money.getOrDefault(table.getName(), List.of()))
{
table.getField(field).setDisplayFormat(DisplayFormat.CURRENCY);
}
}
/*******************************************************************************
** RC.6 supports a per-widget field list, preserving each full child table.
*******************************************************************************/
public static void configureChildList(QWidgetMetaData widget, String childTable)
{
List<String> fields = CHILD_FIELDS.get(childTable);
if(fields != null)
{
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(fields));
}
}
}
@@ -27,10 +27,8 @@ package com.kingsrook.qbits.crm;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -43,6 +41,15 @@ public class CrmQBitConfig implements QBitConfig
{
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
private String tenantSecurityKeyType;
private String tenantAllAccessKeyName;
private String apiName;
private String apiVersion;
private String esbDestinationName;
private String schedulerName;
private Boolean schedulesEnabled = true;
private String messagingProviderName;
private String publicBaseUrl;
@@ -51,9 +58,8 @@ public class CrmQBitConfig implements QBitConfig
private String defaultBackendNameForTables;
private Boolean inferBackendNames = true;
private Boolean appEnabled = true;
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private List<QFieldMetaData> securityFields;
private List<RecordSecurityLock> recordSecurityLocks;
@@ -133,48 +139,6 @@ public class CrmQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for securityFields
*******************************************************************************/
public List<QFieldMetaData> getSecurityFields()
{
return (this.securityFields);
}
/*******************************************************************************
** Fluent setter for securityFields
*******************************************************************************/
public CrmQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
{
this.securityFields = securityFields;
return (this);
}
/*******************************************************************************
** Getter for recordSecurityLocks
*******************************************************************************/
public List<RecordSecurityLock> getRecordSecurityLocks()
{
return (this.recordSecurityLocks);
}
/*******************************************************************************
** Fluent setter for recordSecurityLocks
*******************************************************************************/
public CrmQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
{
this.recordSecurityLocks = recordSecurityLocks;
return (this);
}
/*******************************************************************************
** Whether to infer snake_case database column names.
*******************************************************************************/
@@ -195,6 +159,24 @@ public class CrmQBitConfig implements QBitConfig
}
/*******************************************************************************
** Whether to publish the standalone CRM app.
*******************************************************************************/
public Boolean getAppEnabled()
{
return (appEnabled);
}
/*******************************************************************************
** Allow a host to put CRM tables in its own app.
*******************************************************************************/
public CrmQBitConfig withAppEnabled(Boolean appEnabled)
{
this.appEnabled = appEnabled;
return (this);
}
/*******************************************************************************
** Getter for tokenSecret
@@ -252,4 +234,133 @@ public class CrmQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Scheduler configuration: schedulerName.
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Configure schedulerName.
*******************************************************************************/
public CrmQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Scheduler configuration: schedulesEnabled.
*******************************************************************************/
public Boolean getSchedulesEnabled()
{
return (schedulesEnabled);
}
/*******************************************************************************
** Configure schedulesEnabled.
*******************************************************************************/
public CrmQBitConfig withSchedulesEnabled(Boolean schedulesEnabled)
{
this.schedulesEnabled = schedulesEnabled;
return (this);
}
/*******************************************************************************
** Getter for tenantSecurityKeyType.
*******************************************************************************/
public String getTenantSecurityKeyType()
{
return (tenantSecurityKeyType);
}
/*******************************************************************************
** Configure tenantSecurityKeyType; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withTenantSecurityKeyType(String tenantSecurityKeyType)
{
this.tenantSecurityKeyType = tenantSecurityKeyType;
return (this);
}
/*******************************************************************************
** Getter for tenantAllAccessKeyName.
*******************************************************************************/
public String getTenantAllAccessKeyName()
{
return (tenantAllAccessKeyName);
}
/*******************************************************************************
** Configure tenantAllAccessKeyName; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withTenantAllAccessKeyName(String tenantAllAccessKeyName)
{
this.tenantAllAccessKeyName = tenantAllAccessKeyName;
return (this);
}
/*******************************************************************************
** Getter for apiName.
*******************************************************************************/
public String getApiName()
{
return (apiName);
}
/*******************************************************************************
** Configure apiName; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withApiName(String apiName)
{
this.apiName = apiName;
return (this);
}
/*******************************************************************************
** Getter for apiVersion.
*******************************************************************************/
public String getApiVersion()
{
return (apiVersion);
}
/*******************************************************************************
** Configure apiVersion; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withApiVersion(String apiVersion)
{
this.apiVersion = apiVersion;
return (this);
}
/*******************************************************************************
** Getter for esbDestinationName.
*******************************************************************************/
public String getEsbDestinationName()
{
return (esbDestinationName);
}
/*******************************************************************************
** Configure esbDestinationName; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withEsbDestinationName(String esbDestinationName)
{
this.esbDestinationName = esbDestinationName;
return (this);
}
}
@@ -26,19 +26,31 @@
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;
import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.Currency;
import com.kingsrook.qbits.crm.core.model.FormSubmission;
import com.kingsrook.qbits.crm.deals.customizers.DealInitializationCustomizer;
import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomizer;
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;
import com.kingsrook.qbits.crm.security.CrmRelationshipValidator;
import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData;
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
@@ -53,14 +65,21 @@ import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.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;
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;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
@@ -71,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.
*******************************************************************************/
@@ -103,20 +150,47 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
join.setName(snakeCase(join.getName()));
}
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()));
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE));
QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker();
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker));
widget.setGridColumns(com.kingsrook.qbits.crm.widgets.CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
{
widget.withDefaultValue("joinName", snakeCase(joinName));
String normalizedJoinName = snakeCase(joinName);
widget.withDefaultValue("joinName", normalizedJoinName);
QJoinMetaData join = metaDataProducerMultiOutput.getEach(QJoinMetaData.class).stream()
.filter(candidate -> candidate.getName().equals(normalizedJoinName)).findFirst().orElseThrow();
CrmPresentation.configureChildList(widget, join.getRightTable());
QTableMetaData parent = metaDataProducerMultiOutput.get(QTableMetaData.class, join.getLeftTable());
boolean alreadyAttached = parent.getSections() != null && parent.getSections().stream()
.anyMatch(section -> section.getWidgetName() != null && snakeCase(section.getWidgetName()).equals(widget.getName()));
if(!alreadyAttached)
{
parent.withSection(new QFieldSection(widget.getName(), widget.getLabel(), new QIcon("list"), Tier.T2, List.of())
.withWidgetName(widget.getName()));
}
}
}
metaDataProducerMultiOutput.get(QTableMetaData.class, Contact.TABLE_NAME).withSection(
new QFieldSection("crm_contact_timeline", "Timeline", new QIcon("history"), Tier.T2, List.of())
.withWidgetName("crm_contact_timeline"));
for(QProcessMetaData process : metaDataProducerMultiOutput.getEach(QProcessMetaData.class))
{
process.setPermissionRules(CrmPermissions.processRules(process.getName()));
process.setSourceQBitName(getQBitMetaData().getName());
Integer seconds = Map.of("crm_process_sequence_steps", 300, "crm_calculate_lead_scores", 3600,
"crm_sync_email_delivery", 900, "crm_expire_consents", 86400).get(process.getName());
if(seconds != null && qBitConfig.getSchedulerName() != null && !Boolean.FALSE.equals(qBitConfig.getSchedulesEnabled()))
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(qBitConfig.getSchedulerName()).withRepeatSeconds(seconds));
}
}
/////////////////////////////////////////////////////////////////////////
@@ -125,6 +199,16 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
{
String tableName = table.getName();
CrmPresentation.configureTable(table);
if(tableName.equals(Contact.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class));
}
if(tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CrmRelationshipValidator.class));
addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CrmRelationshipValidator.class));
}
table.setPermissionRules(CrmPermissions.tableRules(tableName));
table.setSourceQBitName(getQBitMetaData().getName());
if(table.getSections() != null)
@@ -149,11 +233,26 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|| tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
{
table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
RecordFeaturesMetaData features = RecordFeaturesMetaData.ofOrWithNew(table)
.withOwnership(new RecordOwnership().withOwnerFieldName("ownerUserId"));
for(RecordFeature feature : List.of(RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.ATTACHMENTS,
RecordFeature.FOLLOW, RecordFeature.TIMELINE, RecordFeature.RECENTS, RecordFeature.FAVORITES))
{
features.withEnabled(feature, true);
}
if(!tableName.equals(Activity.TABLE_NAME))
{
features.withEnabled(RecordFeature.TASKS, true).withEnabled(RecordFeature.ASSIGNMENT, true);
}
}
///////////////////////////////////////////////////////////////
// wire activity timestamp propagation //
///////////////////////////////////////////////////////////////
if(tableName.equals(Contact.TABLE_NAME) || tableName.equals(Deal.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(AutoAssignCustomizer.class));
}
if(tableName.equals(Activity.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(ActivityLastDateCustomizer.class));
@@ -168,6 +267,15 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(DealInitializationCustomizer.class));
}
///////////////////////////////////////////////////////////////
// stamp the exchange-rate date when a rate is set or changed //
///////////////////////////////////////////////////////////////
if(tableName.equals(Currency.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CurrencyRateDateCustomizer.OnInsert.class));
addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CurrencyRateDateCustomizer.OnUpdate.class));
}
///////////////////////////////////////////////////////////////
// wire deal product recalculation //
///////////////////////////////////////////////////////////////
@@ -236,10 +344,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
*******************************************************************************/
public static QAppSection produceAppSection()
{
return new QAppSection()
.withName("crm")
.withLabel("CRM")
.withIcon(new QIcon().withName("contacts"));
return (CrmAppMetaData.mainSection());
}
@@ -295,13 +400,25 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
output.add(item instanceof QWidgetMetaData widget
? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
}
if(!Boolean.FALSE.equals(qBitConfig.getAppEnabled()))
{
output.add(CrmAppMetaData.build(output, getQBitMetaData().getName()));
}
output.add(new QAlertTypeMetaData().withName("crm.lead.received").withLabel("New web lead")
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
output.add(new QAlertTypeMetaData().withName("crm.sequence.failing").withLabel("Sequence needs attention")
.withSourceQBitName(getQBitMetaData().getName()));
output.add(new QAlertTypeMetaData().withName("crm.email.unavailable").withLabel("Email unavailable")
.withSourceQBitName(getQBitMetaData().getName()));
for(String notification : List.of("crm.lead.assigned", "crm.deal.won", "crm.deal.rotting", "crm.task.due"))
{
output.add(new QAlertTypeMetaData().withName(notification).withLabel(notification)
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
}
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
registerHooks(qInstance);
// Registration is metadata-only; core startup sync guards hosts without helpContent.
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
return (output);
}
@@ -0,0 +1,656 @@
/*
* 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.actions;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess;
import com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess;
import com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess;
import com.kingsrook.qbits.crm.core.processes.MergeContactsProcess;
import com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess;
import com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess;
import com.kingsrook.qbits.crm.deals.processes.CloneDealProcess;
import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess;
import com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.email.CrmEmails;
import com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess;
import com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess;
import com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Shared streamed action lifecycle. Review never invokes a mutating operation.
*******************************************************************************/
public final class CrmAction
{
private static final QLogger LOG = QLogger.getLogger(CrmAction.class);
/*******************************************************************************
** Utility holder.
*******************************************************************************/
private CrmAction()
{
}
/*******************************************************************************
** Pin extraction to metadata and require an explicit record selection/filter.
*******************************************************************************/
public static class Extract extends ExtractViaQueryStep
{
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String table = CrmActions.action(input.getProcessName()).table();
input.addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
input.addValue(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
super.preRun(input, output);
}
/*******************************************************************************
** Reuse the core selection parser for preparation and merge validation.
*******************************************************************************/
QQueryFilter selection(RunBackendStepInput input) throws QException
{
if(!StringUtils.hasContent(input.getValueString("queryFilterJson"))
&& (input.getCallback() == null || input.getCallback().getQueryFilter() == null))
{
throw (new QUserFacingException("Select records before running this action"));
}
return (super.getQueryFilter(input).clone());
}
@Override
protected QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException
{
return (selection(input));
}
}
/*******************************************************************************
** Populate read-only pipeline context and the merge survivor dropdown.
*******************************************************************************/
public static class Prepare implements BackendStep
{
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CrmActions.Action action = CrmActions.action(input.getProcessName());
QQueryFilter filter = new Extract().selection(input);
if(action.operation().equals("merge"))
{
List<QRecord> rows = selectedPair(action, input);
output.addValue("mergeIds", new ArrayList<>(rows.stream().map(r -> r.getValue("id")).toList()));
output.addValue("primaryId", rows.getFirst().getValue("id"));
}
else
{
List<QRecord> rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter.withLimit(2))
.withFieldNamesToInclude(Set.of("pipelineId")).withSelectDistinct(true)).getRecords();
List<Serializable> pipelines = rows.stream().map(r -> r.getValue("pipelineId")).distinct().toList();
if(pipelines.size() != 1)
{
throw (new QUserFacingException("Select deals from one pipeline to choose a target stage"));
}
output.addValue("pipelineId", pipelines.getFirst());
}
}
}
/*******************************************************************************
** Validate and describe each selected row, preserving keys for standard links.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
/*******************************************************************************
** Resolve the host transport; tests may supply a fake.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
private final Map<String, List<Serializable>> duplicateGroups = new LinkedHashMap<>();
private List<QRecord> pair;
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CrmActions.Action action = CrmActions.action(input.getProcessName());
if(action.operation().equals("merge"))
{
pair = selectedPair(action, input);
if(pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), input.getValue("primaryId"))))
{
throw (new QUserFacingException("The record to keep must be one of the two selected records"));
}
}
}
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CrmActions.Action action = CrmActions.action(input.getProcessName());
for(QRecord extracted : input.getRecords())
{
Serializable id = extracted.getValue("id");
try
{
QRecord row = requireRow(action, input, id);
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
if(reason != null)
{
add(lines, Status.WARNING, reason, reason, id);
continue;
}
if(action.operation().equals("merge"))
{
if(!Objects.equals(id, input.getValue("primaryId")))
{
continue;
}
Serializable secondary = pair.stream().map(r -> r.getValue("id")).filter(key -> !key.equals(id)).findFirst().orElseThrow();
requireRow(action, input, secondary);
row.setValue("mergeSecondaryId", secondary);
}
if(action.operation().equals("duplicates"))
{
String key = row.getValueString(action.table().equals("crm_contact") ? "email" : "domain");
if(StringUtils.hasContent(key))
{
duplicateGroups.computeIfAbsent(key.toLowerCase(Locale.ROOT), ignored -> new ArrayList<>()).add(id);
}
}
String future = action.operation().equals("auto") ? "will be evaluated for assignment" : "will be " + action.past();
add(lines, Status.OK, future, "was " + action.past(), id);
output.addRecord(row);
}
catch(QUserFacingException e)
{
add(lines, Status.WARNING, e.getMessage(), e.getMessage(), id);
}
catch(QException | RuntimeException e)
{
LOG.warn("CRM action validation failed", e);
add(lines, Status.ERROR, "cannot be processed; validation failed (see server log)", "was not processed; validation failed (see server log)", id);
}
}
}
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> summary = new ArrayList<>(lines.values());
int group = 0;
for(List<Serializable> ids : duplicateGroups.values())
{
if(ids.size() > 1)
{
ProcessSummaryLine line = line(Status.WARNING, "will appear in duplicate group " + (++group), "appeared in duplicate group " + group);
ids.forEach(line::incrementCountAndAddPrimaryKey);
summary.add(line);
}
}
return (summary);
}
}
/*******************************************************************************
** Reauthorize and validate at execution time; count only actual outcomes.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
/*******************************************************************************
** Resolve the host transport; tests may supply a fake.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
private boolean duplicates;
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CrmActions.Action action = CrmActions.action(input.getProcessName());
duplicates = action.operation().equals("duplicates");
for(QRecord reviewed : input.getRecords())
{
Serializable id = reviewed.getValue("id");
try
{
QRecord row = requireRow(action, input, id);
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
if(reason != null)
{
add(lines, Status.WARNING, reason, reason, id);
continue;
}
row.setValue("mergeSecondaryId", reviewed.getValue("mergeSecondaryId"));
String skipped = perform(action, input, row, action.operation().equals("email") ? emailTransport() : null);
if(skipped == null)
{
add(lines, Status.OK, "will be " + action.past(), "was " + action.past(), id);
}
else
{
add(lines, Status.WARNING, skipped, skipped, id);
}
}
catch(QException | RuntimeException e)
{
LOG.warn("CRM action execution failed", e);
String reason = e instanceof QUserFacingException ? e.getMessage() : "see server log";
add(lines, Status.ERROR, "cannot be processed: " + reason, "failed: " + reason, id);
}
}
}
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> summary = new ArrayList<>(lines.values());
if(getTransformStep() instanceof Transform transform)
{
if(duplicates)
{
return (transform.getProcessSummary(output, result));
}
transform.lines.values().stream().filter(line -> line.getStatus() != Status.OK).forEach(summary::add);
}
return (summary);
}
}
/*******************************************************************************
** Read current records under the session; read access alone cannot mutate.
*******************************************************************************/
private static QRecord requireRow(CrmActions.Action action, RunBackendStepInput input, Serializable id) throws QException
{
TablePermissionSubType permission = action.operation().equals("duplicates") ? TablePermissionSubType.READ : TablePermissionSubType.EDIT;
if(!PermissionsHelper.hasProcessPermission(input, action.name()) || !PermissionsHelper.hasTablePermission(input, action.table(), permission))
{
throw (new QUserFacingException("Permission denied for this action"));
}
QRecord row = GetAction.execute(action.table(), id);
if(row == null)
{
throw (new QUserFacingException("Record is unavailable"));
}
return (row);
}
/*******************************************************************************
** Merge only an explicit pair, including when called outside the UI.
*******************************************************************************/
private static List<QRecord> selectedPair(CrmActions.Action action, RunBackendStepInput input) throws QException
{
QQueryFilter filter = new Extract().selection(input);
filter.setLimit(3);
List<QRecord> rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter)).getRecords();
if(rows.size() != 2)
{
throw (new QUserFacingException("Select exactly two records to merge"));
}
return (rows);
}
/*******************************************************************************
** Shared read-only checks for Review and Result.
*******************************************************************************/
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
{
for(QFieldMetaData field : CrmActions.inputFields(action))
{
Serializable value = input.getValue(field.getName());
if(Boolean.TRUE.equals(field.getIsRequired()) && (value == null || value instanceof String text && text.isBlank()))
{
return ("Supply " + field.getLabel() + " before reviewing");
}
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
{
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
{
return (field.getLabel() + " is unavailable");
}
}
}
switch(action.operation())
{
case "email":
return (CrmEmails.skipReason(row, transport));
case "subscribe", "unsubscribe":
if(!StringUtils.hasContent(row.getValueString("email")))
{
return ("Contact has no email address");
}
break;
case "enroll":
if(Boolean.TRUE.equals(row.getValueBoolean("doNotEmail")))
{
return ("Contact has opted out of email");
}
if(!Boolean.TRUE.equals(GetAction.execute("crm_email_sequence", input.getValue("sequenceId")).getValueBoolean("isActive")))
{
return ("Sequence is not active");
}
if(!enrollments(input, row).isEmpty())
{
return ("Contact is already enrolled in this sequence");
}
break;
case "unenroll":
if(enrollments(input, row).isEmpty())
{
return ("Contact has no active enrollment in this sequence");
}
break;
case "stage", "won", "lost", "reopen":
QRecord target = targetStage(action, input, row);
if(target == null)
{
return ("No matching target stage exists in the deal's pipeline");
}
if(!Objects.equals(row.getValue("pipelineId"), target.getValue("pipelineId")))
{
return ("Target stage does not belong to this deal's pipeline");
}
if(Objects.equals(row.getValue("pipelineStageId"), target.getValue("id")))
{
return ("Deal is already in the target stage");
}
if(action.operation().equals("reopen"))
{
QRecord current = GetAction.execute("crm_pipeline_stage", row.getValue("pipelineStageId"));
if(current == null || !closed(current) || closed(target))
{
return ("Reopen requires a closed deal and an open target stage");
}
}
if(StringUtils.hasContent(target.getValueString("requiredFieldsJson")))
{
try
{
for(Object field : JsonUtils.toObject(target.getValueString("requiredFieldsJson"), List.class))
{
if(row.getValue(field.toString()) == null)
{
return ("Required field '" + field + "' must be set before moving to this stage");
}
}
}
catch(java.io.IOException e)
{
throw (new QException("Invalid stage required fields", e));
}
}
break;
case "createDeal":
if(query("crm_pipeline_stage", "pipelineId", input.getValue("pipelineId")).isEmpty())
{
return ("The pipeline has no stages");
}
break;
default:
break;
}
return (null);
}
/*******************************************************************************
** Terminal stage lookup stays within each selected deal's pipeline.
*******************************************************************************/
private static QRecord targetStage(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
{
if(List.of("stage", "reopen").contains(action.operation()))
{
return (GetAction.execute("crm_pipeline_stage", input.getValue("targetStageId")));
}
List<QRecord> stages = new QueryAction().execute(new QueryInput("crm_pipeline_stage").withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, row.getValue("pipelineId")))
.withCriteria(new QFilterCriteria(action.operation().equals("won") ? "isClosedWon" : "isClosedLost", QCriteriaOperator.EQUALS, true))
.withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")).withLimit(1))).getRecords();
return (stages.isEmpty() ? null : stages.getFirst());
}
/*******************************************************************************
** Closed-stage predicate.
*******************************************************************************/
private static boolean closed(QRecord stage)
{
return (Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")));
}
/*******************************************************************************
** Invoke the existing business operations only at Load time.
*******************************************************************************/
private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
{
RunBackendStepInput call = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(new HashMap<>());
for(QFieldMetaData field : CrmActions.inputFields(action))
{
call.addValue(field.getName(), input.getValue(field.getName()));
}
Serializable id = row.getValue("id");
call.addValue("contactId", action.table().equals("crm_contact") ? id : input.getValue("contactId"));
call.addValue("companyId", action.table().equals("crm_company") ? id : null);
call.addValue("dealId", action.table().equals("crm_deal") ? id : null);
call.addValue("formSubmissionId", id);
RunBackendStepOutput result = new RunBackendStepOutput();
switch(action.operation())
{
case "email" ->
{
String processUUID = input.getValueString("crmEmailProcessUUID");
if(processUUID == null)
{
throw (new QUserFacingException("Email process identity is missing; start the Send Email action again"));
}
CrmEmails.send(transport, row, input.getValueInteger("emailTemplateId"), input.getValueString("subject"), input.getValueString("body"),
"email", id + ":" + processUUID, null);
}
case "assign" -> update(action.table(), new QRecord().withValue("id", id).withValue("ownerUserId", input.getValue("newOwnerUserId")));
case "auto" ->
{
call.addValue("entityType", "CONTACT");
call.addValue("recordId", id);
new AutoAssignProcess().run(call, result);
if(!"ASSIGNED".equals(result.getValue("action")))
{
return ("No assignment rule matched; owner unchanged");
}
}
case "subscribe", "unsubscribe" ->
{
call.addValue("email", row.getValue("email"));
call.addValue("firstName", row.getValue("firstName"));
call.addValue("subscriptionSource", "CRM record action");
if(action.operation().equals("subscribe"))
{
new SubscribeToListProcess().run(call, result);
}
else
{
new UnsubscribeFromListProcess().run(call, result);
}
if(List.of("NOOP", "NOT_FOUND").contains(result.getValue("action")))
{
return ("List membership unchanged");
}
}
case "enroll" -> new EnrollInSequenceProcess().run(call, result);
case "unenroll" ->
{
for(QRecord enrollment : enrollments(input, row))
{
call.addValue("enrollmentId", enrollment.getValue("id"));
new UnenrollFromSequenceProcess().run(call, result);
}
}
case "activity" -> new LogActivityProcess().run(call, result);
case "score" -> new CalculateLeadScoresProcess().recalculate(row);
case "merge" ->
{
Serializable secondary = row.getValue("mergeSecondaryId");
List<QRecord> pair = selectedPair(action, input);
if(secondary == null || secondary.equals(id) || pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), secondary)))
{
throw (new QUserFacingException("Merge selection changed; review it again"));
}
requireRow(action, input, secondary);
String entity = action.table().equals("crm_contact") ? "Contact" : "Company";
call.addValue("primary" + entity + "Id", id);
call.addValue("secondary" + entity + "Id", secondary);
if(entity.equals("Contact"))
{
new MergeContactsProcess().run(call, result);
}
else
{
new MergeCompaniesProcess().run(call, result);
}
}
case "duplicates" ->
{
}
case "createDeal" -> new ConvertLeadProcess().run(call, result);
case "erase" -> new GdprAnonymizeContactProcess().run(call, result);
case "stage", "won", "lost", "reopen" ->
{
QRecord change = new QRecord().withValue("id", id).withValue("pipelineStageId", targetStage(action, input, row).getValue("id"));
if(List.of("won", "lost").contains(action.operation()))
{
change.setValue("winLossReasonId", input.getValue("winLossReasonId"));
}
update(action.table(), change);
}
case "clone" -> new CloneDealProcess().run(call, result);
case "addContact" ->
{
call.addValue("action", "ADD");
new ManageContactOnDealProcess().run(call, result);
}
case "convert" -> new ConvertFormToContactProcess().run(call, result);
case "activate", "pause" -> update(action.table(), new QRecord().withValue("id", id).withValue("isActive", action.operation().equals("activate")));
default -> throw (new QUserFacingException("Action is not available"));
}
return (null);
}
/*******************************************************************************
** Core DML can return record errors; never present those as successful writes.
*******************************************************************************/
private static void update(String table, QRecord change) throws QException
{
List<QRecord> records = new UpdateAction().execute(new UpdateInput(table).withRecord(change)).getRecords();
for(QRecord record : records)
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw (new QUserFacingException("Update was rejected: " + record.getErrors()));
}
}
}
/*******************************************************************************
** Active enrollments belonging to the selected contact and chosen sequence.
*******************************************************************************/
private static List<QRecord> enrollments(RunBackendStepInput input, QRecord row) throws QException
{
return (new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, row.getValue("id")))
.withCriteria(new QFilterCriteria("sequenceId", QCriteriaOperator.EQUALS, input.getValue("sequenceId")))
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))).getRecords());
}
/*******************************************************************************
** Session-scoped related rows.
*******************************************************************************/
private static List<QRecord> query(String table, String field, Serializable value) throws QException
{
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)))).getRecords());
}
/*******************************************************************************
** Standard future and past messages work on both Review and Result.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String future, String past)
{
return (new ProcessSummaryLine(status).withSingularFutureMessage(future).withPluralFutureMessage(future)
.withSingularPastMessage(past).withPluralPastMessage(past.startsWith("was ") ? "were " + past.substring(4) : past));
}
/*******************************************************************************
** Group equal outcomes and retain primary keys for standard record links.
*******************************************************************************/
private static void add(Map<String, ProcessSummaryLine> lines, Status status, String future, String past, Serializable id)
{
lines.computeIfAbsent(status + ":" + future, ignored -> line(status, future, past)).incrementCountAndAddPrimaryKey(id);
}
}
@@ -0,0 +1,201 @@
/*
* 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.actions;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
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.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** CRM record actions. The installed definition owns the table and operation.
*******************************************************************************/
public class CrmActions implements MetaDataProducerInterface<MetaDataProducerMultiOutput>
{
/*******************************************************************************
** Immutable action definition; no session state is shared between runs.
*******************************************************************************/
public record Action(String name, String table, String label, String operation, String past)
{
}
public static final List<Action> ACTIONS = List.of(
new Action("crm_bulk_assign_owner", "crm_contact", "Assign Owner", "assign", "assigned a new owner"),
new Action("crm_auto_assign", "crm_contact", "Auto-Assign", "auto", "auto-assigned"),
new Action("crm_subscribe_to_list", "crm_contact", "Add to List", "subscribe", "added to the list"),
new Action("crm_unsubscribe_from_list", "crm_contact", "Remove from List", "unsubscribe", "removed from the list"),
new Action("crm_enroll_in_sequence", "crm_contact", "Enroll in Sequence", "enroll", "enrolled in the sequence"),
new Action("crm_unenroll_from_sequence", "crm_contact", "Unenroll from Sequence", "unenroll", "unenrolled from the sequence"),
new Action("crm_log_activity", "crm_contact", "Log Activity", "activity", "given a logged activity"),
new Action("crm_recalculate_score", "crm_contact", "Recalculate Score", "score", "rescored"),
new Action("crm_merge_contacts", "crm_contact", "Merge Contacts", "merge", "merged"),
new Action("crm_detect_duplicates", "crm_contact", "Find Duplicates", "duplicates", "checked for duplicates"),
new Action("crm_convert_lead", "crm_contact", "Create Deal", "createDeal", "given a new deal"),
new Action("crm_gdpr_anonymize_contact", "crm_contact", "Erase Personal Data", "erase", "anonymized"),
new Action("crm_send_email", "crm_contact", "Send Email", "email", "sent email"),
new Action("crm_assign_company_owner", "crm_company", "Assign Owner", "assign", "assigned a new owner"),
new Action("crm_merge_companies", "crm_company", "Merge Companies", "merge", "merged"),
new Action("crm_find_duplicate_companies", "crm_company", "Find Duplicates", "duplicates", "checked for duplicates"),
new Action("crm_log_company_activity", "crm_company", "Log Activity", "activity", "given a logged activity"),
new Action("crm_move_deal_stage", "crm_deal", "Move Stage", "stage", "moved to the target stage"),
new Action("crm_mark_deal_won", "crm_deal", "Mark Won", "won", "marked won"),
new Action("crm_mark_deal_lost", "crm_deal", "Mark Lost", "lost", "marked lost"),
new Action("crm_reopen_deal", "crm_deal", "Reopen", "reopen", "reopened"),
new Action("crm_clone_deal", "crm_deal", "Clone", "clone", "cloned"),
new Action("crm_transfer_ownership", "crm_deal", "Assign Owner", "assign", "assigned a new owner"),
new Action("crm_manage_contact_on_deal", "crm_deal", "Add Contact", "addContact", "linked to the contact"),
new Action("crm_log_deal_activity", "crm_deal", "Log Activity", "activity", "given a logged activity"),
new Action("crm_convert_form_to_contact", "crm_form_submission", "Convert to Contact", "convert", "converted to contacts"),
new Action("crm_activate_sequence", "crm_email_sequence", "Activate", "activate", "activated"),
new Action("crm_pause_sequence", "crm_email_sequence", "Pause", "pause", "paused"));
/*******************************************************************************
** Resolve exclusively by the installed process name, never client values.
*******************************************************************************/
public static Action action(String name)
{
return (ACTIONS.stream().filter(a -> a.name().equals(name)).findFirst().orElseThrow());
}
/*******************************************************************************
** Package-discovered metadata for every UI action.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance)
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
ACTIONS.forEach(action -> output.add(metadata(action)));
return (output);
}
/*******************************************************************************
** Input details precede the standard ETL Review and Result screens.
*******************************************************************************/
public static QProcessMetaData metadata(Action action)
{
Map<String, Serializable> values = Map.of(
StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, action.table(),
StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, action.table(),
StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true,
StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true,
StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT,
StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, action.label() + " for the selected records.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(CrmAction.Extract.class, CrmAction.Transform.class, CrmAction.Load.class, values)
.withName(action.name()).withLabel(action.label()).withTableName(action.table()).withMinInputRecords(1)
.withIcon(new QIcon("play_arrow")).withPermissionRules(CrmPermissions.processRules(action.name()));
if(action.operation().equals("merge"))
{
process.withMinInputRecords(2).withMaxInputRecords(2);
}
List<QFieldMetaData> fields = inputFields(action);
if(!fields.isEmpty())
{
QFrontendStepMetaData details = new QFrontendStepMetaData().withName("details").withLabel(action.label())
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
fields.forEach(details::withFormField);
process.withStep(0, details);
if(List.of("stage", "reopen", "merge").contains(action.operation()))
{
process.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(CrmAction.Prepare.class)));
}
}
return (process);
}
/*******************************************************************************
** Required user input, including server-validated contingent stage choices.
*******************************************************************************/
public static List<QFieldMetaData> inputFields(Action action)
{
List<QFieldMetaData> fields = new ArrayList<>();
switch(action.operation())
{
case "email" ->
{
fields.add(field("emailTemplateId", "Template", QFieldType.INTEGER, "crm_email_template").withIsRequired(false));
fields.add(field("subject", "Subject", QFieldType.STRING, null).withIsRequired(false));
fields.add(field("body", "Body", QFieldType.TEXT, null).withIsRequired(false));
}
case "assign" -> fields.add(field("newOwnerUserId", "Owner", QFieldType.STRING, null));
case "subscribe", "unsubscribe" -> fields.add(field("emailListId", "List", QFieldType.INTEGER, "crm_email_list"));
case "enroll", "unenroll" -> fields.add(field("sequenceId", "Sequence", QFieldType.INTEGER, "crm_email_sequence"));
case "merge" -> fields.add(field("primaryId", "Record to Keep", QFieldType.INTEGER, action.table())
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, "${input.mergeIds}"))));
case "stage", "reopen" ->
{
fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline").withIsEditable(false));
fields.add(field("targetStageId", "Target Stage", QFieldType.INTEGER, "crm_pipeline_stage")
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}"))));
}
case "won", "lost" -> fields.add(field("winLossReasonId", "Win/Loss Reason", QFieldType.INTEGER, "crm_win_loss_reason"));
case "createDeal" ->
{
fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline"));
fields.add(field("dealName", "Deal Name", QFieldType.STRING, null));
fields.add(field("dealAmount", "Amount", QFieldType.DECIMAL, null).withIsRequired(false));
}
case "addContact" ->
{
fields.add(field("contactId", "Contact", QFieldType.INTEGER, "crm_contact"));
fields.add(field("contactRoleId", "Role", QFieldType.INTEGER, "crm_contact_role").withIsRequired(false));
fields.add(field("isPrimary", "Primary Contact", QFieldType.BOOLEAN, null).withIsRequired(false));
}
case "activity" ->
{
fields.add(field("activityTypeId", "Activity Type", QFieldType.INTEGER, "crm_activity_type"));
fields.add(field("subject", "Subject", QFieldType.STRING, null));
fields.add(field("description", "Description", QFieldType.TEXT, null).withIsRequired(false));
}
default ->
{
}
}
return (fields);
}
/*******************************************************************************
** Consistent required field metadata.
*******************************************************************************/
private static QFieldMetaData field(String name, String label, QFieldType type, String pvs)
{
return (new QFieldMetaData(name, type).withLabel(label).withIsRequired(true).withPossibleValueSourceName(pvs));
}
}
@@ -1,121 +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.
*/
/*******************************************************************************
** Scheduled process that fires reminders for activities with isReminderSet
** = true and reminderDate <= now. Clears the reminder flag after processing.
*******************************************************************************/
package com.kingsrook.qbits.crm.activities.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for firing reminders. Intended to run on a
** 5-minute schedule. Finds activities with active reminders whose
** reminderDate has passed, clears the flag, and would fire notifications
** in a production environment.
*******************************************************************************/
public class FireRemindersProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_fire_reminders";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Fire Reminders")
.withIcon(new QIcon().withName("alarm"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(FireRemindersProcess.class))
));
}
/*******************************************************************************
** Execute: find activities with due reminders and clear the reminder flag.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
///////////////////////////////////////////////
// query activities with pending reminders //
///////////////////////////////////////////////
QueryOutput queryOutput = new QueryAction().execute(
new QueryInput(Activity.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("isReminderSet", QCriteriaOperator.EQUALS, true))
.withCriteria(new QFilterCriteria("reminderDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false))));
int firedCount = 0;
for(QRecord record : queryOutput.getRecords())
{
/////////////////////////////////////////////////////////////
// In production this would fire a notification. For now, //
// just clear the reminder flag. //
/////////////////////////////////////////////////////////////
QRecord updateRecord = new QRecord()
.withValue("id", record.getValueInteger("id"))
.withValue("isReminderSet", false);
new UpdateAction().execute(
new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord));
firedCount++;
}
output.addValue("firedCount", firedCount);
}
}
@@ -27,13 +27,7 @@
package com.kingsrook.qbits.crm.activities.processes;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityOutcome;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -42,62 +36,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep that creates a crm_activity record from
** Backend operation used by the streamed CRM actions that creates a crm_activity record from
** the provided input fields. Sets ownerUserId from the current session.
*******************************************************************************/
public class LogActivityProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class LogActivityProcess implements BackendStep
{
public static final String NAME = "crm_log_activity";
/*******************************************************************************
** Produce the process metadata with input fields for all activity fields.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Log Activity")
.withTableName(Activity.TABLE_NAME)
.withIcon(new QIcon().withName("event_note"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(LogActivityProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("activityTypeId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(ActivityType.TABLE_NAME),
new QFieldMetaData("subject", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("description", QFieldType.STRING),
new QFieldMetaData("contactId", QFieldType.INTEGER).withPossibleValueSourceName(Contact.TABLE_NAME),
new QFieldMetaData("companyId", QFieldType.INTEGER).withPossibleValueSourceName(Company.TABLE_NAME),
new QFieldMetaData("dealId", QFieldType.INTEGER),
new QFieldMetaData("direction", QFieldType.INTEGER).withPossibleValueSourceName(CrmDirection.NAME),
new QFieldMetaData("durationMinutes", QFieldType.INTEGER),
new QFieldMetaData("outcomeId", QFieldType.INTEGER).withPossibleValueSourceName(ActivityOutcome.TABLE_NAME),
new QFieldMetaData("location", QFieldType.STRING),
new QFieldMetaData("conferenceLink", QFieldType.STRING),
new QFieldMetaData("startDate", QFieldType.DATE_TIME),
new QFieldMetaData("endDate", QFieldType.DATE_TIME)
)))
));
}
/*******************************************************************************
** Execute the process: build an Activity entity from inputs and insert it.
*******************************************************************************/
@@ -1,156 +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.
*/
/*******************************************************************************
** Scheduled process that finds overdue tasks and marks them with a system
** note. In production this would fire notifications; for now it logs a
** message on each overdue activity.
*******************************************************************************/
package com.kingsrook.qbits.crm.activities.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for processing overdue tasks. Intended to
** run on an hourly schedule. Finds activities of type "Task" with dueDate
** in the past and isCompleted=false.
*******************************************************************************/
public class ProcessOverdueTasksProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_process_overdue_tasks";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Process Overdue Tasks")
.withIcon(new QIcon().withName("warning"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(ProcessOverdueTasksProcess.class))
));
}
/*******************************************************************************
** Execute: find overdue tasks and mark them with a system note.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
///////////////////////////////////////////////
// look up the "Task" activity type //
///////////////////////////////////////////////
Integer taskTypeId = lookupTaskActivityTypeId();
if(taskTypeId == null)
{
output.addValue("processedCount", 0);
return;
}
///////////////////////////////////////////////
// query overdue, incomplete tasks //
///////////////////////////////////////////////
QueryOutput queryOutput = new QueryAction().execute(
new QueryInput(Activity.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("activityTypeId", QCriteriaOperator.EQUALS, taskTypeId))
.withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now()))
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false))));
int processedCount = 0;
for(QRecord record : queryOutput.getRecords())
{
/////////////////////////////////////////////////////////////////////
// In production this would create a notification. For now, add a //
// system note to the description indicating it is overdue. //
/////////////////////////////////////////////////////////////////////
String currentDescription = record.getValueString("description");
String note = "[OVERDUE] Task past due date.";
String newDescription = currentDescription != null ? currentDescription + " " + note : note;
QRecord updateRecord = new QRecord()
.withValue("id", record.getValueInteger("id"))
.withValue("description", newDescription);
new UpdateAction().execute(
new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord));
processedCount++;
}
output.addValue("processedCount", processedCount);
}
/***************************************************************************
** Look up the "Task" activity type id. Returns null if not found.
***************************************************************************/
private Integer lookupTaskActivityTypeId() throws QException
{
QueryOutput queryOutput = new QueryAction().execute(
new QueryInput(ActivityType.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Task"))));
if(!queryOutput.getRecords().isEmpty())
{
return (queryOutput.getRecords().get(0).getValueInteger("id"));
}
return (null);
}
}
@@ -27,7 +27,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
@@ -288,13 +287,14 @@ public class CrmIntakeApi
QRecord contact;
if(contacts.isEmpty())
{
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource();
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource().strip();
source = source.length() > 100 ? source.substring(0, 100) : source;
List<QRecord> sources = store.by("crm_lead_source", "name", source);
QRecord leadSource = sources.isEmpty() ? store.insert("crm_lead_source", new QRecord().withValue("name", source).withValue("sortOrder", 0)) : sources.get(0);
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
.withValue("phone", request.phone()).withValue("ownerUserId", null)
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
}
else
@@ -323,7 +323,7 @@ public class CrmIntakeApi
}
update.withValue("lastTouchDate", Instant.now());
store.update("crm_contact", update);
if(contacts.isEmpty() && isUnassigned(contact.getValueString("ownerUserId")))
if(contacts.isEmpty() && isUnassigned(store.get("crm_contact", contact.getValueInteger("id")).getValueString("ownerUserId")))
{
CrmAutoAssign.assign(CrmAssignableEntity.CONTACT, contact.getValueInteger("id"));
}
@@ -395,6 +395,6 @@ public class CrmIntakeApi
*******************************************************************************/
private String name(String value)
{
return (value == null || value.isBlank() ? "Not provided" : value);
return (value == null || value.isBlank() ? null : value.strip());
}
}
@@ -0,0 +1,86 @@
/*
* 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.bulkload;
import java.io.IOException;
import java.io.InputStream;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfile;
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
/*******************************************************************************
** Starter mappings for the host's core bulk loader. The host owns its tables.
*******************************************************************************/
public final class CrmBulkLoadProfiles
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmBulkLoadProfiles()
{
}
/*******************************************************************************
** Call once during host startup, after core profile tables are installed.
** Existing profiles, including user edits, are preserved by label.
*******************************************************************************/
public static void ensure() throws QException
{
if(QContext.getQInstance().getTable(SavedBulkLoadProfile.TABLE_NAME) == null)
{
return;
}
try(InputStream stream = CrmBulkLoadProfiles.class.getResourceAsStream("/bulkload/crm-profiles.json"))
{
ObjectMapper mapper = new ObjectMapper();
for(JsonNode profile : mapper.readTree(stream))
{
String label = profile.get("label").asText();
if(!new QueryAction().execute(new QueryInput(SavedBulkLoadProfile.TABLE_NAME).withFilter(new QQueryFilter(
new QFilterCriteria("label", QCriteriaOperator.EQUALS, label)).withLimit(1))).getRecords().isEmpty())
{
continue;
}
BulkLoadProfile mapping = mapper.treeToValue(profile.get("mapping"), BulkLoadProfile.class);
CrmRecords.insert(SavedBulkLoadProfile.TABLE_NAME, new QRecord().withValue("label", label)
.withValue("tableName", profile.get("tableName").asText()).withValue("isBulkEdit", false)
.withValue("mappingJson", mapper.writeValueAsString(mapping)));
}
}
catch(IOException | IllegalArgumentException e)
{
throw (new QException("Could not load CRM bulk-load profiles", e));
}
}
}
@@ -0,0 +1,72 @@
/*
* 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.core;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Checked writes for background work: record errors must not advance checkpoints.
*******************************************************************************/
public final class CrmRecords
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmRecords()
{
}
/*******************************************************************************
** Insert and require a successful persisted row.
*******************************************************************************/
public static QRecord insert(String table, QRecord row) throws QException
{
return (check(new InsertAction().execute(new InsertInput(table).withRecord(row)).getRecords()));
}
/*******************************************************************************
** Update and require a successful persisted row.
*******************************************************************************/
public static QRecord update(String table, QRecord row) throws QException
{
return (check(new UpdateAction().execute(new UpdateInput(table).withRecord(row)).getRecords()));
}
/*******************************************************************************
** DML errors are failed work, never successful job progress.
*******************************************************************************/
private static QRecord check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || rows.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
{
throw (new QException("CRM write was rejected"));
}
return (rows.getFirst());
}
}
@@ -0,0 +1,73 @@
/*
* 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.core.customizers;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** RC.6 recordLabelFormat is printf-only; preserve a computed query/get label.
*******************************************************************************/
public class ContactLabelCustomizer extends AbstractPostQueryCustomizer
{
/*******************************************************************************
** Keep missing names absent in storage while giving records useful labels.
*******************************************************************************/
@Override
public List<QRecord> apply(List<QRecord> records)
{
for(QRecord record : records)
{
QRecord visible = new QRecord().withValue("id", record.getValue("id"));
for(String name : List.of("firstName", "lastName", "email"))
{
var field = QContext.getQInstance().getTable(Contact.TABLE_NAME).getField(name);
if(FieldSecurityLockHelper.isFieldAccessible(field)
&& !(input != null && input.getShouldOmitHiddenFields() && field.getIsHidden())
&& !(input != null && input.getShouldMaskPasswords() && field.getType().needsMasked()))
{
visible.setValue(name, record.getValue(name));
}
}
record.setRecordLabel(label(visible));
}
return (records);
}
/*******************************************************************************
** Prefer the supplied name, then email, then the record identifier.
*******************************************************************************/
public static String label(QRecord record)
{
String name = (Objects.toString(record.getValue("firstName"), "").strip() + " "
+ Objects.toString(record.getValue("lastName"), "").strip()).strip();
return (name.isBlank() ? Objects.toString(record.getValue("email"), Objects.toString(record.getValue("id"), "Contact")) : name);
}
}
@@ -0,0 +1,117 @@
/*
* 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.core.customizers;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Stamps crm_currency.rateLastUpdatedDate whenever a rate is set or changed, so
** the date says when the exchange rate was last touched. Editing only the name,
** symbol or active flag leaves it alone.
*******************************************************************************/
public final class CurrencyRateDateCustomizer
{
public static final String RATE_FIELD = "exchangeRateToBase";
public static final String DATE_FIELD = "rateLastUpdatedDate";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CurrencyRateDateCustomizer()
{
}
/*******************************************************************************
** A new currency has a rate from the moment it exists.
*******************************************************************************/
public static class OnInsert extends AbstractPreInsertCustomizer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> apply(List<QRecord> records) throws QException
{
Instant now = Instant.now();
for(QRecord record : records)
{
record.setValue(DATE_FIELD, now);
}
return (records);
}
}
/*******************************************************************************
** An update that carries a different rate than the stored one moves the date.
*******************************************************************************/
public static class OnUpdate extends AbstractPreUpdateCustomizer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> apply(List<QRecord> records) throws QException
{
if(isPreview || oldRecordList == null)
{
return (records);
}
Instant now = Instant.now();
for(QRecord record : records)
{
QRecord oldRecord = getOldRecordMap().get(record.getValue("id"));
if(oldRecord != null && record.getValues().containsKey(RATE_FIELD)
&& !sameRate(ValueUtils.getValueAsBigDecimal(record.getValue(RATE_FIELD)), ValueUtils.getValueAsBigDecimal(oldRecord.getValue(RATE_FIELD))))
{
record.setValue(DATE_FIELD, now);
}
}
return (records);
}
}
/*******************************************************************************
** Numeric equality, so 1 and 1.000000 are the same rate.
*******************************************************************************/
static boolean sameRate(BigDecimal a, BigDecimal b)
{
return (a == null ? b == null : b != null && a.compareTo(b) == 0);
}
}
@@ -29,7 +29,7 @@ package com.kingsrook.qbits.crm.core.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -45,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
/*******************************************************************************
@@ -63,6 +60,9 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
produceTableMetaData = true,
tableMetaDataCustomizer = Company.TableMetaDataCustomizer.class,
childTables = {
@ChildTable(childTableEntityClass = Activity.class, joinFieldName = "companyId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = Contact.class, joinFieldName = "companyId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Contacts", enabled = true, maxRows = 50)),
@@ -104,30 +104,7 @@ public class Company extends QRecordEntity
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
///////////////////////////////////////////////////////////////
// inject security fields and locks from CrmQBitConfig //
///////////////////////////////////////////////////////////////
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
if(config != null)
{
if(config.getSecurityFields() != null)
{
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
{
table.addField(fieldMetaData.clone());
t1Section.getFieldNames().add(fieldMetaData.getName());
}
}
if(config.getRecordSecurityLocks() != null)
{
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
table.withRecordSecurityLock(lock);
}
}
}
return (table);
}
@@ -28,9 +28,11 @@ package com.kingsrook.qbits.crm.core.model;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
import com.kingsrook.qbits.crm.privacy.CrmPersonalData.ErasureFlag;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer;
@@ -111,6 +113,12 @@ public class ConsentRecord extends QRecordEntity
@Override
public List<QRecord> apply(List<QRecord> records) throws QException
{
if(updateInput != null && updateInput.hasFlag(ErasureFlag.REDACT_CONSENT)
&& records.stream().allMatch(row -> Set.of("id", "consentText").containsAll(row.getValues().keySet())
&& "erased".equals(row.getValueString("consentText"))))
{
return (records);
}
throw new QUserFacingException("Consent records cannot be modified or deleted");
}
}
@@ -28,9 +28,10 @@ package com.kingsrook.qbits.crm.core.model;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.deals.model.DealContact;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.email.model.EmailMessage;
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -44,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
/*******************************************************************************
@@ -62,12 +60,24 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
produceTableMetaData = true,
tableMetaDataCustomizer = Contact.TableMetaDataCustomizer.class,
childTables = {
@ChildTable(childTableEntityClass = Deal.class, joinFieldName = "primaryContactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = EmailListMember.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "List Memberships", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = SequenceEnrollment.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Enrollments", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = EmailMessage.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Email Messages", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = FormSubmission.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Web Leads", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = Activity.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 50)),
@ChildTable(childTableEntityClass = DealContact.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = ConsentRecord.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Consent Records", enabled = true, maxRows = 25))
@@ -91,51 +101,25 @@ public class Contact extends QRecordEntity
@Override
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
{
QFieldSection t1Section = SectionFactory.defaultT1("id", "firstName", "lastName", "email", "phone");
QFieldSection t1Section = SectionFactory.defaultT1("firstName", "lastName", "email", "phone");
table
.withIcon(new QIcon().withName("person"))
.withRecordLabelFormat("%s %s")
.withRecordLabelFields("firstName", "lastName")
.withRecordLabelFormat(null)
.withRecordLabelFields(List.of())
.withSection(t1Section)
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
List.of("companyId", "jobTitle", "department", "lifecycleStageId", "leadSourceId",
"ownerUserId", "leadScore", "timezone", "preferredLanguage")))
.withSection(new QFieldSection("online", new QIcon().withName("language"), Tier.T2,
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl")))
.withSection(new QFieldSection("address", new QIcon().withName("location_on"), Tier.T2,
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
"ownerUserId", "leadScore", "timezone", "preferredLanguage", "description")))
.withSection(new QFieldSection("contactInformation", "Contact Information", new QIcon("contact_mail"), Tier.T2,
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl", "addressLine1", "addressLine2", "city", "state", "postalCode", "country",
"doNotEmail", "doNotCall", "unsubscribeDate")))
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
.withSection(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3,
List.of("id", "lastActivityDate", "lastContactedDate", "createDate", "modifyDate")));
///////////////////////////////////////////////////////////////
// inject security fields and locks from CrmQBitConfig //
///////////////////////////////////////////////////////////////
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
if(config != null)
{
if(config.getSecurityFields() != null)
{
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
{
table.addField(fieldMetaData.clone());
t1Section.getFieldNames().add(fieldMetaData.getName());
}
}
if(config.getRecordSecurityLocks() != null)
{
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
table.withRecordSecurityLock(lock);
}
}
}
return (table);
}
@@ -143,19 +127,19 @@ public class Contact extends QRecordEntity
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmSource;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmMedium;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmCampaign;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmContent;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmTerm;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -167,19 +151,19 @@ public class Contact extends QRecordEntity
@QField()
private Instant firstTouchDate;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmSource;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmMedium;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmCampaign;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmContent;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmTerm;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -194,16 +178,16 @@ public class Contact extends QRecordEntity
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstName;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastName;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String email;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String secondaryEmail;
@QField(maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -227,7 +211,7 @@ public class Contact extends QRecordEntity
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
private Integer leadSourceId;
@QField(isRequired = true)
@QField()
private String ownerUserId;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -106,7 +106,7 @@ public class Currency extends QRecordEntity
@QField(isRequired = true, defaultValue = "true")
private Boolean isActive;
@QField()
@QField(isEditable = false)
private Instant rateLastUpdatedDate;
@QField(isEditable = false)
@@ -35,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
@@ -48,7 +51,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
@QMetaDataProducingEntity(
producePossibleValueSource = true,
produceTableMetaData = true,
tableMetaDataCustomizer = EmailList.TableMetaDataCustomizer.class
tableMetaDataCustomizer = EmailList.TableMetaDataCustomizer.class,
childTables = {
@ChildTable(childTableEntityClass = EmailListMember.class, joinFieldName = "emailListId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Members", enabled = true, maxRows = 25))
}
)
public class EmailList extends QRecordEntity
{
@@ -123,7 +123,7 @@ public class EmailListMember extends QRecordEntity
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String email;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -129,7 +129,7 @@ public class FormSubmission extends QRecordEntity
@QField(isRequired = true, possibleValueSourceName = CrmFormType.NAME)
private Integer formType;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String email;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -150,19 +150,19 @@ public class FormSubmission extends QRecordEntity
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String sourceUrl;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmSource;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmMedium;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmCampaign;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmTerm;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmContent;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -40,7 +40,13 @@ public enum CrmEmailStatus implements PossibleValueEnum<Integer>
DRAFT(1, "Draft"),
SENT(2, "Sent"),
DELIVERED(3, "Delivered"),
FAILED(4, "Failed");
FAILED(4, "Failed"),
QUEUED(5, "Queued"),
BOUNCED(6, "Bounced"),
COMPLAINED(7, "Complained"),
OPENED(8, "Opened"),
CLICKED(9, "Clicked"),
SUPPRESSED(10, "Suppressed");
private final Integer id;
private final String label;
@@ -1,124 +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.
*/
/*******************************************************************************
** Bulk action process to assign a new owner to selected records. Works on
** any table that has an ownerUserId field.
*******************************************************************************/
package com.kingsrook.qbits.crm.core.processes;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** MetaDataProducer for the BulkAssignOwner table-level process. Uses the
** StreamedETL pattern with a transform step that sets ownerUserId on each
** record to the newOwnerUserId input value.
*******************************************************************************/
public class BulkAssignOwnerProcess implements MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_bulk_assign_owner";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(NAME)
.withLabel("Bulk Assign Owner")
.withTableName(Contact.TABLE_NAME)
.withIcon(new QIcon().withName("person_pin"))
.withTransformStepClass(BulkAssignOwnerTransformStep.class)
.withPreviewMessage("Records to be assigned a new owner")
.withReviewStepRecordFields(List.of(
new QFieldMetaData("id", QFieldType.INTEGER),
new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Current Owner")
))
.getProcessMetaData();
return (process);
}
/*******************************************************************************
** Transform step that sets ownerUserId on each record.
*******************************************************************************/
public static class BulkAssignOwnerTransformStep extends AbstractTransformStep
{
private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
.withSingularFutureMessage("will be assigned a new owner")
.withPluralFutureMessage("will be assigned a new owner")
.withSingularPastMessage("was assigned a new owner")
.withPluralPastMessage("were assigned a new owner");
/*******************************************************************************
** Set ownerUserId on each record from the newOwnerUserId input.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String newOwnerUserId = input.getValueString("newOwnerUserId");
for(QRecord record : input.getRecords())
{
record.setValue("ownerUserId", newOwnerUserId);
output.addRecord(record);
okLine.incrementCountAndAddPrimaryKey(record.getValueInteger("id"));
}
}
/*******************************************************************************
** Provide process summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
okLine.addSelfToListIfAnyCount(rs);
return (rs);
}
}
}
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qbits.crm.core.model.Contact;
@@ -54,52 +53,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** MetaDataProducer + BackendStep for converting a form submission into a
** Backend operation used by the streamed CRM actions for converting a form submission into a
** contact record. Deduplicates on email, updates the form with conversion
** data, and creates a Note activity on the contact.
*******************************************************************************/
public class ConvertFormToContactProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class ConvertFormToContactProcess implements BackendStep
{
public static final String NAME = "crm_convert_form_to_contact";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Convert Form to Contact")
.withTableName(FormSubmission.TABLE_NAME)
.withIcon(new QIcon().withName("swap_horiz"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(ConvertFormToContactProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("formSubmissionId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the conversion process.
*******************************************************************************/
@@ -41,49 +41,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** MetaDataProducer + BackendStep for detecting duplicate contacts or companies.
** Backend operation used by the streamed CRM actions for detecting duplicate contacts or companies.
*******************************************************************************/
public class DetectDuplicatesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class DetectDuplicatesProcess implements BackendStep
{
public static final String NAME = "crm_detect_duplicates";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Detect Duplicates")
.withIcon(new QIcon().withName("find_replace"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(DetectDuplicatesProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the detect duplicates process.
*******************************************************************************/
@@ -18,180 +18,30 @@
* limitations under the License.
*/
/*******************************************************************************
** GDPR right-to-erasure process. Anonymizes PII on a contact, scrubs audit
** log entries, deletes attachments, unenrolls from sequences, creates a
** consent-withdrawn record, and logs the anonymization event.
*******************************************************************************/
package com.kingsrook.qbits.crm.core.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.ConsentRecord;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.privacy.CrmPersonalData;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for GDPR anonymization of a contact.
** Backend operation for the streamed Erase Personal Data action.
*******************************************************************************/
public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class GdprAnonymizeContactProcess implements BackendStep
{
private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class);
public static final String NAME = "crm_gdpr_anonymize_contact";
private static final String ANONYMIZED = "[ANONYMIZED]";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("GDPR Anonymize Contact")
.withTableName(Contact.TABLE_NAME)
.withIcon(new QIcon().withName("privacy_tip"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(GdprAnonymizeContactProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the GDPR anonymization process.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Integer contactId = input.getValueInteger("contactId");
///////////////////////////////////////////
// Step 1: load and validate contact //
///////////////////////////////////////////
QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId);
if(contactRecord == null)
{
throw new QException("Contact not found: " + contactId);
}
///////////////////////////////////////////
// Step 2: anonymize PII fields //
///////////////////////////////////////////
QRecord updateRecord = new QRecord()
.withValue("id", contactId)
.withValue("firstName", ANONYMIZED)
.withValue("lastName", ANONYMIZED)
.withValue("email", "anonymized-" + contactId + "@removed.invalid")
.withValue("phone", null)
.withValue("mobilePhone", null)
.withValue("secondaryEmail", null)
.withValue("addressLine1", null)
.withValue("addressLine2", null)
.withValue("city", null)
.withValue("state", null)
.withValue("postalCode", null)
.withValue("country", null)
.withValue("website", null)
.withValue("linkedinUrl", null)
.withValue("description", null)
.withValue("doNotEmail", true)
.withValue("doNotCall", true);
new UpdateAction().execute(
new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord));
///////////////////////////////////////////
// Step 3: unenroll from sequences //
///////////////////////////////////////////
unenrollFromSequences(contactId);
///////////////////////////////////////////
// Step 4: create consent record //
///////////////////////////////////////////
new InsertAction().execute(
new InsertInput(ConsentRecord.TABLE_NAME).withRecordEntity(
new ConsentRecord()
.withContactId(contactId)
.withConsentType(CrmConsentType.DATA_PROCESSING.getPossibleValueId())
.withStatus(CrmConsentStatus.WITHDRAWN.getPossibleValueId())
.withSource("GDPR Anonymization Process")
.withConsentDate(Instant.now())));
Integer contactId = input.getValueInteger("contactId");
CrmPersonalData.erase(contactId);
output.addValue("contactId", contactId);
output.addValue("anonymized", true);
}
/***************************************************************************
** Unenroll from any active sequence enrollments for this contact.
***************************************************************************/
private void unenrollFromSequences(Integer contactId) throws QException
{
try
{
QueryOutput enrollments = new QueryAction().execute(
new QueryInput("crm_sequence_enrollment")
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))));
for(QRecord record : enrollments.getRecords())
{
QRecord updateRecord = new QRecord()
.withValue("id", record.getValueInteger("id"))
.withValue("status", CrmEnrollmentStatus.UNENROLLED.getPossibleValueId());
new UpdateAction().execute(
new UpdateInput("crm_sequence_enrollment").withRecord(updateRecord));
}
}
catch(Exception e)
{
LOG.warn("Error unenrolling contact from sequences during GDPR anonymization", e);
}
}
}
@@ -1,159 +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.
*/
/*******************************************************************************
** Simplified import process for contacts. Takes a list of QRecords,
** validates required fields, checks for duplicates by email, inserts new
** contacts, with core table auditing.
*******************************************************************************/
package com.kingsrook.qbits.crm.core.processes;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
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.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** MetaDataProducer + BackendStep for importing contacts from QRecords.
*******************************************************************************/
public class ImportContactsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_import_contacts";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Import Contacts")
.withTableName(Contact.TABLE_NAME)
.withIcon(new QIcon().withName("upload"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(ImportContactsProcess.class))
));
}
/*******************************************************************************
** Execute the import contacts process.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
List<QRecord> inputRecords = input.getRecords();
String sessionUserId = QContext.getQSession().getIdReference();
////////////////////////////////////////////////////////////
// build set of existing emails for duplicate detection //
////////////////////////////////////////////////////////////
Set<String> existingEmails = new HashSet<>();
QueryOutput existingContacts = new QueryAction().execute(
new QueryInput(Contact.TABLE_NAME));
for(QRecord record : existingContacts.getRecords())
{
String email = record.getValueString("email");
if(StringUtils.hasContent(email))
{
existingEmails.add(email.toLowerCase());
}
}
int importedCount = 0;
int skippedCount = 0;
int errorCount = 0;
for(QRecord inputRecord : inputRecords)
{
String firstName = inputRecord.getValueString("firstName");
String lastName = inputRecord.getValueString("lastName");
String email = inputRecord.getValueString("email");
///////////////////////////////////////////
// validate required fields //
///////////////////////////////////////////
if(!StringUtils.hasContent(firstName) || !StringUtils.hasContent(lastName) || !StringUtils.hasContent(email))
{
errorCount++;
continue;
}
///////////////////////////////////////////
// check for duplicate by email //
///////////////////////////////////////////
if(existingEmails.contains(email.toLowerCase()))
{
skippedCount++;
continue;
}
///////////////////////////////////////////
// insert the new contact //
///////////////////////////////////////////
QRecord newContact = new QRecord()
.withValue("firstName", firstName)
.withValue("lastName", lastName)
.withValue("email", email)
.withValue("phone", inputRecord.getValueString("phone"))
.withValue("ownerUserId", sessionUserId);
new InsertAction().execute(
new InsertInput(Contact.TABLE_NAME).withRecord(newContact));
existingEmails.add(email.toLowerCase());
importedCount++;
}
output.addValue("importedCount", importedCount);
output.addValue("skippedCount", skippedCount);
output.addValue("errorCount", errorCount);
}
}
@@ -50,50 +50,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for merging two companies into one.
** Backend operation used by the streamed CRM actions for merging two companies into one.
*******************************************************************************/
public class MergeCompaniesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class MergeCompaniesProcess implements BackendStep
{
public static final String NAME = "crm_merge_companies";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Merge Companies")
.withTableName(Company.TABLE_NAME)
.withIcon(new QIcon().withName("merge"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(MergeCompaniesProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("primaryCompanyId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("secondaryCompanyId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the merge companies process.
*******************************************************************************/
@@ -52,50 +52,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for merging two contacts into one.
** Backend operation used by the streamed CRM actions for merging two contacts into one.
*******************************************************************************/
public class MergeContactsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class MergeContactsProcess implements BackendStep
{
public static final String NAME = "crm_merge_contacts";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Merge Contacts")
.withTableName(Contact.TABLE_NAME)
.withIcon(new QIcon().withName("merge"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(MergeContactsProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("primaryContactId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("secondaryContactId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the merge contacts process.
*******************************************************************************/
@@ -125,6 +92,8 @@ public class MergeContactsProcess implements BackendStep, MetaDataProducerInterf
// Re-link activities from secondary //
///////////////////////////////////////////////
relinkRecords(Activity.TABLE_NAME, "contactId", secondaryContactId, primaryContactId);
relinkRecords("crm_email_message", "contactId", secondaryContactId, primaryContactId);
relinkRecords("crm_deal", "primaryContactId", secondaryContactId, primaryContactId);
///////////////////////////////////////////////
// Re-link DealContact rows //
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.EmailListMember;
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
@@ -47,54 +46,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** MetaDataProducer + BackendStep to subscribe an email address to a list.
** Backend operation used by the streamed CRM actions to subscribe an email address to a list.
** Handles: new subscription, resubscription after unsub, and no-op for
** existing active subscribers.
*******************************************************************************/
public class SubscribeToListProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class SubscribeToListProcess implements BackendStep
{
public static final String NAME = "crm_subscribe_to_list";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Subscribe to List")
.withIcon(new QIcon().withName("person_add"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(SubscribeToListProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("firstName", QFieldType.STRING),
new QFieldMetaData("subscriptionSource", QFieldType.STRING)
)))
));
}
/*******************************************************************************
** Execute the subscription logic.
*******************************************************************************/
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.EmailListMember;
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
@@ -44,51 +43,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep to unsubscribe an email from a list.
** Backend operation used by the streamed CRM actions to unsubscribe an email from a list.
** Queries by emailListId + email, sets status to UNSUBSCRIBED and
** unsubscribedDate to now.
*******************************************************************************/
public class UnsubscribeFromListProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class UnsubscribeFromListProcess implements BackendStep
{
public static final String NAME = "crm_unsubscribe_from_list";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Unsubscribe from List")
.withIcon(new QIcon().withName("unsubscribe"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(UnsubscribeFromListProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the unsubscription.
*******************************************************************************/
@@ -0,0 +1,108 @@
/*
* 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.deals;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Deal age notifications with a persistent once-per-deal-and-stage checkpoint.
*******************************************************************************/
public final class CrmDealNotifications
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmDealNotifications()
{
}
/*******************************************************************************
** Scan open deals in keyset pages, resolving stage metadata once per run.
*******************************************************************************/
public static void checkRotting() throws QException
{
Map<Integer, QRecord> stages = new HashMap<>();
for(QRecord stage : new QueryAction().execute(new QueryInput("crm_pipeline_stage")).getRecords())
{
stages.put(stage.getValueInteger("id"), stage);
}
Integer lastId = null;
Instant now = Instant.now();
while(true)
{
QQueryFilter filter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_deal").withFilter(filter)).getRecords();
for(QRecord deal : page)
{
lastId = deal.getValueInteger("id");
QRecord stage = stages.get(deal.getValueInteger("pipelineStageId"));
Instant entered = deal.getValueInstant("stageEnteredDate");
if(stage == null || entered == null || deal.getValueString("ownerUserId") == null
|| Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")))
{
continue;
}
Integer days = stage.getValueInteger("rotDays");
if(days == null || days <= 0 || !entered.isBefore(now.minus(days, ChronoUnit.DAYS)))
{
continue;
}
String key = "deal:" + lastId + ":stage:" + stage.getValue("id");
String checkpoint = "crm.deal.rotting:" + key;
if(new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, checkpoint)))).getRecords().isEmpty())
{
QAlerts.notify(new NotificationSignal("Deal needs attention").withSourceName("crm.deal.rotting").withSourceKey(key)
.withSourceTableName("crm_deal").withSourceRecordId(lastId.toString())
.withAudience(NotificationAudience.user(deal.getValueString("ownerUserId"))));
CrmRecords.insert(JobState.TABLE_NAME, new QRecord().withValue("name", checkpoint).withValue("lastSuccess", now));
}
}
if(page.size() < 200)
{
break;
}
}
}
}
@@ -31,6 +31,7 @@ package com.kingsrook.qbits.crm.deals.customizers;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.deals.model.Deal;
@@ -130,6 +131,13 @@ public class DealInitializationCustomizer extends AbstractPostInsertCustomizer
}
}
if(stage != null && (Boolean.TRUE.equals(stage.getIsClosedWon()) || Boolean.TRUE.equals(stage.getIsClosedLost()))
&& record.getValue("actualCloseDate") == null)
{
updateRecord.setValue("actualCloseDate", now.atZone(ZoneOffset.UTC).toLocalDate());
needsUpdate = true;
}
///////////////////////////////////////////
// calculate weightedAmount //
///////////////////////////////////////////
@@ -51,6 +51,9 @@ import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
@@ -192,6 +195,14 @@ public class DealStageChangeCustomizer extends AbstractPreUpdateCustomizer
// insert DealStageHistory row //
///////////////////////////////////////////
insertStageHistoryRow(record, oldRecord, oldStageId, newStageId, now);
String owner = record.getValues().containsKey("ownerUserId") ? record.getValueString("ownerUserId") : oldRecord.getValueString("ownerUserId");
if(Boolean.TRUE.equals(newStage.getIsClosedWon()) && owner != null)
{
QAlerts.notify(new NotificationSignal("Deal won").withSourceName("crm.deal.won")
.withSourceKey("deal:" + record.getValue("id") + ":stage:" + newStageId + ":" + now)
.withSourceTableName(Deal.TABLE_NAME).withSourceRecordId(record.getValueString("id"))
.withAudience(NotificationAudience.user(owner)), updateInput == null ? null : updateInput.getTransaction());
}
}
@@ -30,9 +30,9 @@ import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.LeadSource;
import com.kingsrook.qbits.crm.core.model.enums.CrmDealPriority;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -51,13 +51,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
/*******************************************************************************
@@ -118,36 +115,13 @@ public class Deal extends QRecordEntity
.withRecordLabelFields("name")
.withSection(t1Section)
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
List.of("companyId", "ownerUserId", "leadSourceId", "priority",
List.of("primaryContactId", "companyId", "ownerUserId", "leadSourceId", "priority",
"probabilityOverridePct", "expectedCloseDate", "actualCloseDate",
"winLossReasonId", "weightedAmount", "amountInBaseCurrency")))
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("stageEnteredDate", "lastActivityDate", "createDate", "modifyDate"));
///////////////////////////////////////////////////////////////
// inject security fields and locks from CrmQBitConfig //
///////////////////////////////////////////////////////////////
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
if(config != null)
{
if(config.getSecurityFields() != null)
{
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
{
table.addField(fieldMetaData.clone());
t1Section.getFieldNames().add(fieldMetaData.getName());
}
}
if(config.getRecordSecurityLocks() != null)
{
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
table.withRecordSecurityLock(lock);
}
}
}
///////////////////////////////////////////////////////////
// Only offer stages belonging to the selected pipeline. //
@@ -155,6 +129,9 @@ public class Deal extends QRecordEntity
table.getField("pipelineStageId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}")));
table.getField("primaryContactId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("companyId", QCriteriaOperator.EQUALS, "${input.companyId}")));
return (table);
}
}
@@ -191,7 +168,10 @@ public class Deal extends QRecordEntity
@QField(possibleValueSourceName = Company.TABLE_NAME)
private Integer companyId;
@QField(isRequired = true)
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer primaryContactId;
@QField()
private String ownerUserId;
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
@@ -894,4 +874,32 @@ public class Deal extends QRecordEntity
return (this);
}
/*******************************************************************************
** The contact primarily responsible for this opportunity.
*******************************************************************************/
public Integer getPrimaryContactId()
{
return (primaryContactId);
}
/*******************************************************************************
** Set the primary contact.
*******************************************************************************/
public void setPrimaryContactId(Integer primaryContactId)
{
this.primaryContactId = primaryContactId;
}
/*******************************************************************************
** Set the primary contact fluently.
*******************************************************************************/
public Deal withPrimaryContactId(Integer primaryContactId)
{
this.primaryContactId = primaryContactId;
return (this);
}
}
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.deals.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealContact;
import com.kingsrook.qbits.crm.deals.model.DealProduct;
@@ -50,49 +49,17 @@ 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.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for cloning a deal and its child records.
** Backend operation used by the streamed CRM actions for cloning a deal and its child records.
*******************************************************************************/
public class CloneDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class CloneDealProcess implements BackendStep
{
public static final String NAME = "crm_clone_deal";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Clone Deal")
.withTableName(Deal.TABLE_NAME)
.withIcon(new QIcon().withName("content_copy"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(CloneDealProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the clone deal process.
*******************************************************************************/
@@ -127,6 +94,7 @@ public class CloneDealProcess implements BackendStep, MetaDataProducerInterface<
.withValue("amount", sourceDeal.getAmount())
.withValue("currencyCode", sourceDeal.getCurrencyCode())
.withValue("companyId", sourceDeal.getCompanyId())
.withValue("primaryContactId", sourceDeal.getPrimaryContactId())
.withValue("ownerUserId", sourceDeal.getOwnerUserId())
.withValue("leadSourceId", sourceDeal.getLeadSourceId())
.withValue("expectedCloseDate", sourceDeal.getExpectedCloseDate())
@@ -30,7 +30,6 @@ package com.kingsrook.qbits.crm.deals.processes;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.LifecycleStage;
import com.kingsrook.qbits.crm.deals.model.Deal;
@@ -57,55 +56,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** MetaDataProducer + BackendStep for converting a lead (contact) into a
** Backend operation used by the streamed CRM actions for converting a lead (contact) into a
** deal opportunity. Transitions the contact's lifecycle stage, creates
** a deal with the first pipeline stage, and links them via DealContact.
*******************************************************************************/
public class ConvertLeadProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class ConvertLeadProcess implements BackendStep
{
public static final String NAME = "crm_convert_lead";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Convert Lead")
.withTableName(Contact.TABLE_NAME)
.withIcon(new QIcon().withName("trending_up"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(ConvertLeadProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("pipelineId", QFieldType.INTEGER),
new QFieldMetaData("dealName", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("dealAmount", QFieldType.DECIMAL)
)))
));
}
/*******************************************************************************
** Execute the lead conversion process.
*******************************************************************************/
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealContact;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
@@ -49,55 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for adding or removing a contact on a deal.
** Backend operation used by the streamed CRM actions for adding or removing a contact on a deal.
** ADD: inserts a DealContact row, with isPrimary enforcement.
** REMOVE: deletes the DealContact row matching dealId + contactId.
*******************************************************************************/
public class ManageContactOnDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class ManageContactOnDealProcess implements BackendStep
{
public static final String NAME = "crm_manage_contact_on_deal";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Manage Contact on Deal")
.withTableName(Deal.TABLE_NAME)
.withIcon(new QIcon().withName("person_add"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(ManageContactOnDealProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("action", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("contactRoleId", QFieldType.INTEGER),
new QFieldMetaData("isPrimary", QFieldType.BOOLEAN)
)))
));
}
/*******************************************************************************
** Execute the manage contact process.
*******************************************************************************/
@@ -131,6 +96,11 @@ public class ManageContactOnDealProcess implements BackendStep, MetaDataProducer
new InsertAction().execute(
new InsertInput(DealContact.TABLE_NAME).withRecordEntity(dealContact));
if(Boolean.TRUE.equals(isPrimary))
{
new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(new QRecord()
.withValue("id", dealId).withValue("primaryContactId", contactId)));
}
}
else if("REMOVE".equalsIgnoreCase(action))
{
@@ -156,6 +126,12 @@ public class ManageContactOnDealProcess implements BackendStep, MetaDataProducer
deleteInput.setTableName(DealContact.TABLE_NAME);
deleteInput.setPrimaryKeys(idsToDelete.stream().map(id -> (java.io.Serializable) id).toList());
new DeleteAction().execute(deleteInput);
QRecord deal = GetAction.execute(Deal.TABLE_NAME, dealId);
if(deal != null && contactId.equals(deal.getValueInteger("primaryContactId")))
{
new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(new QRecord()
.withValue("id", dealId).withValue("primaryContactId", null)));
}
}
else
{
@@ -50,55 +50,22 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** MetaDataProducer + BackendStep for moving a deal to a new pipeline stage.
** Backend operation used by the streamed CRM actions for moving a deal to a new pipeline stage.
** Enforces stage-belongs-to-pipeline validation, required field gating,
** weighted amount recalculation, close-date stamping, and stage history
** tracking.
*******************************************************************************/
public class MoveDealStageProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class MoveDealStageProcess implements BackendStep
{
public static final String NAME = "crm_move_deal_stage";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Move Deal Stage")
.withTableName(Deal.TABLE_NAME)
.withIcon(new QIcon().withName("swap_horiz"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(MoveDealStageProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the stage move process.
*******************************************************************************/
@@ -31,7 +31,6 @@ package com.kingsrook.qbits.crm.deals.processes;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
@@ -47,52 +46,19 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for reopening a closed deal by moving it
** Backend operation used by the streamed CRM actions for reopening a closed deal by moving it
** to an active pipeline stage. Validates the deal is currently closed, the
** target stage is not closed, clears close data, and logs the transition.
*******************************************************************************/
public class ReopenDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class ReopenDealProcess implements BackendStep
{
public static final String NAME = "crm_reopen_deal";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Reopen Deal")
.withTableName(Deal.TABLE_NAME)
.withIcon(new QIcon().withName("replay"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(ReopenDealProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the reopen process.
*******************************************************************************/
@@ -27,7 +27,6 @@
package com.kingsrook.qbits.crm.deals.processes;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
@@ -46,50 +45,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for transferring ownership of contacts,
** Backend operation used by the streamed CRM actions for transferring ownership of contacts,
** companies, and deals from one user to another.
*******************************************************************************/
public class TransferOwnershipProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class TransferOwnershipProcess implements BackendStep
{
public static final String NAME = "crm_transfer_ownership";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Transfer Ownership")
.withIcon(new QIcon().withName("swap_horiz"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(TransferOwnershipProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("fromUserId", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("toUserId", QFieldType.STRING).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the transfer ownership process.
*******************************************************************************/
@@ -0,0 +1,55 @@
/*
* 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.email;
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
/*******************************************************************************
** Carry the core process UUID across Review/Result and retries in process state.
*******************************************************************************/
public class CrmEmailProcessIdentity implements ProcessLifecycleListenerInterface
{
@Override
public boolean appliesTo(String processName)
{
return ("crm_send_email".equals(processName));
}
@Override
public void onProcessStarted(RunProcessInput input)
{
input.getProcessState().getValues().put("crmEmailProcessUUID", input.getProcessUUID());
}
@Override
public void onProcessCompleted(RunProcessInput input, RunProcessOutput output)
{
}
@Override
public void onProcessFailed(RunProcessInput input, Exception exception)
{
}
}
@@ -21,8 +21,11 @@
package com.kingsrook.qbits.crm.email;
import java.time.Instant;
import java.util.Optional;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiEffectOutcome;
@@ -87,6 +90,14 @@ public class CrmEmailTransport
** Deliver one message, using a stable CRM purpose/id key for the durable email outbox.
*******************************************************************************/
public void send(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
deliver(purpose, id, contactId, address, subject, body, marketing);
}
/*******************************************************************************
** Return the transport result without guessing that queued email was sent.
*******************************************************************************/
private Delivery deliver(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
if(emailInstalled())
{
@@ -98,7 +109,9 @@ public class CrmEmailTransport
{
throw (new QUserFacingException("The email service could not accept this message."));
}
return;
var message = result.message();
return (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));
}
if(readiness() != CrmReadiness.CORE_MESSAGING)
{
@@ -107,5 +120,52 @@ public class CrmEmailTransport
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName(config.getMessagingProviderName())
.withTo(new Party().withAddress(address).withRole(EmailPartyRole.TO)).withSubject(subject)
.withContent(new Content().withBody(body).withContentRole(EmailContentRole.TEXT)));
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
}
/*******************************************************************************
** CRM-owned delivery snapshot; optional email types never escape this adapter.
*******************************************************************************/
public record Delivery(String messageId, CrmEmailStatus status, Instant sentDate, Instant deliveredDate,
Instant openedDate, Instant clickedDate, Instant bouncedDate)
{
}
/*******************************************************************************
** Send and return the actual outbox state, or the successful core send state.
*******************************************************************************/
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
send(purpose, id, contactId, address, subject, body, marketing);
return (lookup("crm:" + purpose + ":" + id).orElseGet(() ->
new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null)));
}
/*******************************************************************************
** Read delivery through the published API only. RC.3 exposes sent/delivered dates.
*******************************************************************************/
public Optional<Delivery> lookup(String key) throws QException
{
if(!emailInstalled() || key == null)
{
return (Optional.empty());
}
return (EmailApi.getMessageByIdempotencyKey(key).map(message -> new Delivery(String.valueOf(message.id()),
status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null)));
}
/*******************************************************************************
** Preserve known delivery states and leave unknown/pending states eligible for sync.
*******************************************************************************/
private CrmEmailStatus status(String status)
{
try
{
return (CrmEmailStatus.valueOf(status));
}
catch(IllegalArgumentException | NullPointerException e)
{
return (CrmEmailStatus.QUEUED);
}
}
}
@@ -0,0 +1,167 @@
/*
* 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.email;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.api.ActivityKind;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Shared CRM template rendering, transport delivery and timeline recording.
*******************************************************************************/
public final class CrmEmails
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmEmails()
{
}
/*******************************************************************************
** A skip reason suitable for standard WARNING summary lines.
*******************************************************************************/
public static String skipReason(QRecord contact, CrmEmailTransport transport) throws QException
{
if(Boolean.TRUE.equals(contact.getValueBoolean("doNotEmail")))
{
return ("Contact has opted out of email");
}
if(contact.getValueString("email") == null || contact.getValueString("email").isBlank())
{
return ("Contact has no email address");
}
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED ? "CRM email is not configured" : null);
}
/*******************************************************************************
** Send once using the caller's stable purpose/id and record the transport outcome.
*******************************************************************************/
public static void send(CrmEmailTransport transport, QRecord contact, Integer templateId, String subject, String body,
String purpose, String id, Integer enrollmentId) throws QException
{
String key = "crm:" + purpose + ":" + id;
List<QRecord> existing = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(
new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.EQUALS, key)))).getRecords();
if(!existing.isEmpty())
{
recordActivity(existing.getFirst());
return;
}
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);
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)
.withValue("direction", CrmDirection.OUTBOUND.getId()).withValue("status", delivery.status().getId())
.withValue("idempotencyKey", key).withValue("emailMessageId", delivery.messageId())
.withValue("sentDate", delivery.sentDate()).withValue("deliveredDate", delivery.deliveredDate()));
recordActivity(message);
}
/*******************************************************************************
** Repair a partially recorded send without sending again or duplicating its activity.
*******************************************************************************/
private static void recordActivity(QRecord message) throws QException
{
if(message.getValue("activityId") != null)
{
return;
}
String description = "Email: " + message.getValueString("idempotencyKey");
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, message.getValue("contactId")),
new QFilterCriteria("description", QCriteriaOperator.EQUALS, description));
List<QRecord> activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
if(activities.isEmpty())
{
new CrmMarketingApi().recordActivity(message.getValueInteger("contactId"),
message.getValue("sequenceEnrollmentId") == null ? ActivityKind.EMAIL : ActivityKind.SEQUENCE_STEP,
message.getValueString("subject"), description);
activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
}
CrmRecords.update("crm_email_message", new QRecord().withValue("id", message.getValue("id"))
.withValue("activityId", activities.getFirst().getValue("id")));
}
/*******************************************************************************
** Literal replacements avoid treating customer data as a template program.
*******************************************************************************/
public static String render(String text, QRecord contact) throws QException
{
QRecord company = contact.getValue("companyId") == null ? null : GetAction.execute("crm_company", contact.getValue("companyId"));
Map<String, String> fields = Map.of("contact.firstName", value(contact, "firstName"), "contact.lastName", value(contact, "lastName"),
"company.name", value(company, "name"), "owner.name", value(contact, "ownerUserId"));
// Replace each token in the original text only, so inserted values cannot introduce merge fields.
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}").matcher(text);
StringBuilder rendered = new StringBuilder();
while(matcher.find())
{
String replacement = "unsubscribeUrl".equals(matcher.group(1))
? new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null) : fields.get(matcher.group(1));
matcher.appendReplacement(rendered, java.util.regex.Matcher.quoteReplacement(replacement));
}
matcher.appendTail(rendered);
return (rendered.toString());
}
/*******************************************************************************
** Missing merge values render as empty strings.
*******************************************************************************/
private static String value(QRecord row, String field)
{
return (row == null || row.getValueString(field) == null ? "" : row.getValueString(field));
}
}
@@ -29,6 +29,7 @@ package com.kingsrook.qbits.crm.email.model;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType;
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
@@ -45,6 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
@@ -74,6 +76,7 @@ public class EmailMessage extends QRecordEntity
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
{
table
.withUniqueKey(new UniqueKey("idempotencyKey"))
.withIcon(new QIcon().withName("email"))
.withRecordLabelFormat("%s")
.withRecordLabelFields("subject")
@@ -84,10 +87,10 @@ public class EmailMessage extends QRecordEntity
List.of("bodyHtml", "bodyText")))
.withSection(new QFieldSection("tracking", new QIcon("visibility"), Tier.T2,
List.of("isTracked", "openCount", "clickCount", "firstOpenedDate", "firstClickedDate",
"bounceType", "isSpamComplaint")))
"bounceType", "isSpamComplaint", "deliveredDate", "bouncedDate")))
.withSection(new QFieldSection("references", new QIcon("link"), Tier.T2,
List.of("activityId", "emailTemplateId", "sequenceEnrollmentId",
"threadId", "replyToEmailId")))
List.of("contactId", "activityId", "emailTemplateId", "sequenceEnrollmentId",
"threadId", "replyToEmailId", "idempotencyKey", "emailMessageId")))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
@@ -102,7 +105,10 @@ public class EmailMessage extends QRecordEntity
@QField(possibleValueSourceName = Activity.TABLE_NAME)
private Integer activityId;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String fromAddress;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -168,6 +174,18 @@ public class EmailMessage extends QRecordEntity
@QField()
private Integer replyToEmailId;
@QField(maxLength = 250)
private String idempotencyKey;
@QField(maxLength = 100)
private String emailMessageId;
@QField()
private Instant deliveredDate;
@QField()
private Instant bouncedDate;
@QField(isEditable = false)
private Instant createDate;
@@ -999,4 +1017,131 @@ public class EmailMessage extends QRecordEntity
return (this);
}
/*******************************************************************************
** Contact whose record includes this message.
*******************************************************************************/
public Integer getContactId()
{
return (contactId);
}
/*******************************************************************************
** Set the message's contact.
*******************************************************************************/
public void setContactId(Integer contactId)
{
this.contactId = contactId;
}
/*******************************************************************************
** Set the message's contact fluently.
*******************************************************************************/
public EmailMessage withContactId(Integer contactId)
{
this.contactId = contactId;
return (this);
}
/*******************************************************************************
** Get idempotencyKey.
*******************************************************************************/
public String getIdempotencyKey()
{
return (idempotencyKey);
}
/*******************************************************************************
** Set idempotencyKey.
*******************************************************************************/
public EmailMessage withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Get emailMessageId.
*******************************************************************************/
public String getEmailMessageId()
{
return (emailMessageId);
}
/*******************************************************************************
** Set emailMessageId.
*******************************************************************************/
public EmailMessage withEmailMessageId(String emailMessageId)
{
this.emailMessageId = emailMessageId;
return (this);
}
/*******************************************************************************
** Get deliveredDate.
*******************************************************************************/
public Instant getDeliveredDate()
{
return (deliveredDate);
}
/*******************************************************************************
** Set deliveredDate.
*******************************************************************************/
public EmailMessage withDeliveredDate(Instant deliveredDate)
{
this.deliveredDate = deliveredDate;
return (this);
}
/*******************************************************************************
** Get bouncedDate.
*******************************************************************************/
public Instant getBouncedDate()
{
return (bouncedDate);
}
/*******************************************************************************
** Set bouncedDate.
*******************************************************************************/
public EmailMessage withBouncedDate(Instant bouncedDate)
{
this.bouncedDate = bouncedDate;
return (this);
}
/*******************************************************************************
** Set idempotencyKey for entity mapping.
*******************************************************************************/
public void setIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
}
/*******************************************************************************
** Set emailMessageId for entity mapping.
*******************************************************************************/
public void setEmailMessageId(String emailMessageId)
{
this.emailMessageId = emailMessageId;
}
/*******************************************************************************
** Set deliveredDate for entity mapping.
*******************************************************************************/
public void setDeliveredDate(Instant deliveredDate)
{
this.deliveredDate = deliveredDate;
}
/*******************************************************************************
** Set bouncedDate for entity mapping.
*******************************************************************************/
public void setBouncedDate(Instant bouncedDate)
{
this.bouncedDate = bouncedDate;
}
}
@@ -28,6 +28,7 @@ package com.kingsrook.qbits.crm.email.model;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
@@ -97,7 +98,7 @@ public class SequenceEnrollment extends QRecordEntity
@QField(isRequired = true, possibleValueSourceName = EmailSequence.TABLE_NAME)
private Integer sequenceId;
@QField(isRequired = true)
@QField(isRequired = true, possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField()
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.email.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.email.model.EmailSequence;
@@ -51,52 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep to enroll a contact in an email sequence.
** Backend operation used by the streamed CRM actions to enroll a contact in an email sequence.
** Validates that the contact is not already enrolled, the sequence is active,
** and the contact has not opted out of email.
*******************************************************************************/
public class EnrollInSequenceProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class EnrollInSequenceProcess implements BackendStep
{
public static final String NAME = "crm_enroll_in_sequence";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Enroll in Sequence")
.withIcon(new QIcon().withName("playlist_add"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(EnrollInSequenceProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("sequenceId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("dealId", QFieldType.INTEGER)
)))
));
}
/*******************************************************************************
** Execute the enrollment logic.
*******************************************************************************/
@@ -0,0 +1,104 @@
/*
* 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.email.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** Scheduled CRM maintenance: crm_expire_consents.
*******************************************************************************/
public class ExpireConsentsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_expire_consents";
@Override
public QProcessMetaData produce(QInstance instance)
{
return (new QProcessMetaData().withName(NAME).withLabel("ExpireConsents").withIsHidden(true)
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(ExpireConsentsProcess.class))));
}
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Instant now = Instant.now();
Instant cutoff = now.minus(14, ChronoUnit.DAYS);
Integer lastId = null;
int expired = 0;
while(true)
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmListMemberStatus.PENDING_CONFIRMATION.getId()))
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_list_member").withFilter(filter)).getRecords();
for(QRecord member : page)
{
lastId = member.getValueInteger("id");
Instant pendingSince = member.getValueInstant("confirmationSentDate");
if(pendingSince == null)
{
pendingSince = member.getValueInstant("createDate");
}
if(pendingSince != null && !pendingSince.isAfter(cutoff))
{
CrmRecords.update("crm_email_list_member", new QRecord().withValue("id", lastId)
.withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()).withValue("confirmationTokenHash", null)
.withValue("unsubscribedDate", now));
expired++;
}
}
if(page.size() < 200)
{
break;
}
}
output.addValue("expiredPendingCount", expired);
output.addValue("expiredConsentCount", new CountAction().execute(new CountInput("crm_consent_record")
.withFilter(new QQueryFilter(new QFilterCriteria("expiryDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)))).getCount());
}
}
@@ -34,9 +34,15 @@ import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.email.CrmEmails;
import com.kingsrook.qbits.crm.email.model.EmailSequence;
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
import com.kingsrook.qbits.crm.email.model.SequenceStep;
@@ -45,6 +51,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
@@ -53,6 +61,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
@@ -78,6 +87,15 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
private static final int MAX_FAILURES = 3;
/*******************************************************************************
** Resolve the host transport; tests may supply a fake.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
/*******************************************************************************
@@ -88,9 +106,9 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
{
return new QProcessMetaData()
.withName(NAME)
.withIsHidden(true)
.withLabel("Process Sequence Steps")
.withIcon(new QIcon().withName("play_circle"))
// Scheduling is configured by the host application via ScheduledJob records
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
@@ -109,29 +127,49 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
///////////////////////////////////////////////////////////
// query enrollments where status=ACTIVE and due now //
///////////////////////////////////////////////////////////
QueryOutput enrollmentQuery = new QueryAction().execute(
new QueryInput(SequenceEnrollment.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))));
int processedCount = 0;
for(QRecord enrollmentRecord : enrollmentQuery.getRecords())
CrmEmailTransport transport = emailTransport();
boolean ready = transport.readiness() != CrmReadiness.NOT_CONFIGURED;
boolean activeSequences = !new QueryAction().execute(new QueryInput(EmailSequence.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withLimit(1))).getRecords().isEmpty();
if(!ready && activeSequences)
{
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
try
QAlerts.raise(new AlertSignal("crm.email.unavailable").withDedupKey("transport").withTitle("CRM email is unavailable"));
}
else if(ready)
{
QAlerts.resolve("crm.email.unavailable", "transport", "Email transport is ready");
}
int processedCount = 0;
Integer lastId = null;
Instant dueThrough = Instant.now();
while(true)
{
QQueryFilter filter = new QQueryFilter()
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, dueThrough))
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
processEnrollment(enrollment);
processedCount++;
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
catch(Exception e)
List<QRecord> page = new QueryAction().execute(new QueryInput(SequenceEnrollment.TABLE_NAME).withFilter(filter)).getRecords();
for(QRecord enrollmentRecord : page)
{
/////////////////////////////////////////////
// handle step failure //
/////////////////////////////////////////////
handleFailure(enrollment, e.getMessage());
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
try
{
processEnrollment(enrollment, transport);
processedCount++;
}
catch(Exception e)
{
handleFailure(enrollment, e.getMessage());
}
lastId = enrollment.getId();
}
if(page.size() < 200)
{
break;
}
}
@@ -144,7 +182,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
** Process a single enrollment: load the next step and execute it.
** Respects businessDaysOnly and sendWindow settings on the sequence.
***************************************************************************/
private void processEnrollment(SequenceEnrollment enrollment) throws QException
private void processEnrollment(SequenceEnrollment enrollment, CrmEmailTransport transport) throws QException
{
Integer nextStepNumber = enrollment.getCurrentStepNumber() + 1;
@@ -157,6 +195,11 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
EmailSequence sequence = sequenceGet.getRecord() != null
? new EmailSequence(sequenceGet.getRecord()) : null;
if(sequence == null || !Boolean.TRUE.equals(sequence.getIsActive()))
{
return;
}
/////////////////////////////////////////////
// load the contact for timezone //
/////////////////////////////////////////////
@@ -227,7 +270,27 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
{
switch(stepType)
{
case EMAIL -> executeEmailStep(enrollment, step);
case EMAIL ->
{
var eligibility = new CrmMarketingApi().checkEmailEligibility(enrollment.getContactId());
if(!eligibility.eligible())
{
CrmRecords.update(SequenceEnrollment.TABLE_NAME, new QRecord().withValue("id", enrollment.getId())
.withValue("status", CrmEnrollmentStatus.PAUSED.getId()).withValue("lastFailureMessage", eligibility.reason())
.withValue("nextStepDate", null));
return;
}
if(transport.readiness() == CrmReadiness.NOT_CONFIGURED)
{
return;
}
if(step.getEmailTemplateId() == null)
{
throw (new QException("Sequence email template is required"));
}
CrmEmails.send(transport, contactGet.getRecord(), step.getEmailTemplateId(), null, null,
"seq", enrollment.getId() + ":" + step.getId(), enrollment.getId());
}
case CALL -> executeCallStep(enrollment, step);
case TASK -> executeTaskStep(enrollment, step);
case WAIT ->
@@ -247,50 +310,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
/***************************************************************************
** Execute an EMAIL step: verify doNotEmail, create an activity record.
***************************************************************************/
private void executeEmailStep(SequenceEnrollment enrollment, SequenceStep step) throws QException
{
///////////////////////////////////////////////
// check doNotEmail //
///////////////////////////////////////////////
GetOutput contactGet = new GetAction().execute(
new GetInput(Contact.TABLE_NAME).withPrimaryKey(enrollment.getContactId()));
if(contactGet.getRecord() != null)
{
Contact contact = new Contact(contactGet.getRecord());
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
{
throw (new QException("Contact has opted out of email"));
}
}
///////////////////////////////////////////////
// create activity for the email //
///////////////////////////////////////////////
String subject = "Sequence email: step " + step.getStepNumber();
if(step.getEmailTemplateId() != null)
{
subject += " (template #" + step.getEmailTemplateId() + ")";
}
Integer activityTypeId = lookupActivityTypeByName("Email");
new InsertAction().execute(
new InsertInput(Activity.TABLE_NAME).withRecordEntity(
new Activity()
.withActivityTypeId(activityTypeId)
.withContactId(enrollment.getContactId())
.withDealId(enrollment.getDealId())
.withSubject(subject)
.withOwnerUserId("system")
.withIsCompleted(true)));
}
/***************************************************************************
** Execute a CALL step: create a task activity assigned to the contact owner.
***************************************************************************/
@@ -514,6 +533,8 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
{
updateRecord.withValue("status", CrmEnrollmentStatus.FAILED.getPossibleValueId());
updateRecord.withValue("nextStepDate", null);
QAlerts.raise(new AlertSignal("crm.sequence.failing").withDedupKey("enrollment:" + enrollment.getId())
.withTitle("Sequence enrollment failed three times"));
}
new UpdateAction().execute(
@@ -1,235 +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.
*/
/*******************************************************************************
** Process to send an email to a contact. Creates an Activity and an
** EmailMessage record, respecting doNotEmail flags and optional template
** lookups.
*******************************************************************************/
package com.kingsrook.qbits.crm.email.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
import com.kingsrook.qbits.crm.email.model.EmailMessage;
import com.kingsrook.qbits.crm.email.model.EmailTemplate;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for sending an email to a CRM contact.
** Validates doNotEmail, optionally loads a template, creates an Activity
** record, and creates a tracked EmailMessage record.
*******************************************************************************/
public class SendEmailProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_send_email";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Send Email")
.withTableName(Contact.TABLE_NAME)
.withIcon(new QIcon().withName("email"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(SendEmailProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("subject", QFieldType.STRING),
new QFieldMetaData("bodyHtml", QFieldType.STRING),
new QFieldMetaData("bodyText", QFieldType.STRING),
new QFieldMetaData("emailTemplateId", QFieldType.INTEGER),
new QFieldMetaData("dealId", QFieldType.INTEGER),
new QFieldMetaData("ccAddresses", QFieldType.STRING),
new QFieldMetaData("bccAddresses", QFieldType.STRING)
)))
));
}
/*******************************************************************************
** Execute the send-email process.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Integer contactId = input.getValueInteger("contactId");
String subject = input.getValueString("subject");
String bodyHtml = input.getValueString("bodyHtml");
String bodyText = input.getValueString("bodyText");
Integer emailTemplateId = input.getValueInteger("emailTemplateId");
Integer dealId = input.getValueInteger("dealId");
String ccAddresses = input.getValueString("ccAddresses");
String bccAddresses = input.getValueString("bccAddresses");
String sessionUserId = CrmSessionUtils.getCurrentUserId();
///////////////////////////////////////////
// Step 1: load and validate the contact //
///////////////////////////////////////////
QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId);
if(contactRecord == null)
{
throw new QException("Contact not found: " + contactId);
}
Contact contact = new Contact(contactRecord);
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
{
throw new QUserFacingException("Cannot send email: contact has doNotEmail set to true.");
}
//////////////////////////////////////////////////////
// Step 2: if template provided, load and apply it //
//////////////////////////////////////////////////////
if(emailTemplateId != null)
{
QRecord templateRecord = GetAction.execute(EmailTemplate.TABLE_NAME, emailTemplateId);
if(templateRecord != null)
{
EmailTemplate template = new EmailTemplate(templateRecord);
if(subject == null)
{
subject = template.getSubject();
}
if(bodyHtml == null)
{
bodyHtml = template.getBodyHtml();
}
if(bodyText == null)
{
bodyText = template.getBodyText();
}
}
}
/////////////////////////////////////////
// Step 3: look up Email activity type //
/////////////////////////////////////////
Integer emailTypeId = lookupEmailActivityTypeId();
//////////////////////////////////////////
// Step 4: create the Activity record //
//////////////////////////////////////////
Activity activity = new Activity()
.withActivityTypeId(emailTypeId)
.withSubject(subject)
.withContactId(contactId)
.withDealId(dealId)
.withDirection(CrmDirection.OUTBOUND.getPossibleValueId())
.withOwnerUserId(sessionUserId)
.withIsCompleted(true);
InsertOutput activityInsert = new InsertAction().execute(
new InsertInput(Activity.TABLE_NAME).withRecordEntity(activity));
Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id");
//////////////////////////////////////////////
// Step 5: create the EmailMessage record //
//////////////////////////////////////////////
String toAddress = contact.getEmail() != null ? contact.getEmail() : "";
EmailMessage emailMessage = new EmailMessage()
.withActivityId(activityId)
.withFromAddress(sessionUserId)
.withFromName(sessionUserId)
.withToAddresses(toAddress)
.withCcAddresses(ccAddresses)
.withBccAddresses(bccAddresses)
.withSubject(subject != null ? subject : "")
.withBodyHtml(bodyHtml)
.withBodyText(bodyText)
.withDirection(CrmDirection.OUTBOUND.getPossibleValueId())
.withStatus(CrmEmailStatus.SENT.getPossibleValueId())
.withSentDate(Instant.now())
.withIsTracked(true)
.withOpenCount(0)
.withClickCount(0)
.withEmailTemplateId(emailTemplateId);
InsertOutput emailInsert = new InsertAction().execute(
new InsertInput(EmailMessage.TABLE_NAME).withRecordEntity(emailMessage));
Integer emailMessageId = emailInsert.getRecords().get(0).getValueInteger("id");
output.addValue("emailMessageId", emailMessageId);
}
/***************************************************************************
** Look up the "Email" activity type id. Returns null if not found.
***************************************************************************/
private Integer lookupEmailActivityTypeId() throws QException
{
QueryOutput queryOutput = new QueryAction().execute(
new QueryInput(ActivityType.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Email"))));
if(!queryOutput.getRecords().isEmpty())
{
return (queryOutput.getRecords().get(0).getValueInteger("id"));
}
return (null);
}
}
@@ -0,0 +1,153 @@
/*
* 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.email.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** Scheduled CRM maintenance: crm_sync_email_delivery.
*******************************************************************************/
public class SyncEmailDeliveryProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_sync_email_delivery";
@Override
public QProcessMetaData produce(QInstance instance)
{
return (new QProcessMetaData().withName(NAME).withLabel("SyncEmailDelivery").withIsHidden(true)
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(SyncEmailDeliveryProcess.class))));
}
/*******************************************************************************
** Resolve the host transport; an absent qbit-email yields empty lookups.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CrmEmailTransport transport = emailTransport();
Integer lastId = null;
int updated = 0;
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
while(true)
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(filter)).getRecords();
for(QRecord message : page)
{
lastId = message.getValueInteger("id");
if(message.getValueInteger("status") != null && List.of(CrmEmailStatus.BOUNCED.getId(), CrmEmailStatus.COMPLAINED.getId(), CrmEmailStatus.FAILED.getId(),
CrmEmailStatus.SUPPRESSED.getId()).contains(message.getValueInteger("status")))
{
continue;
}
var delivery = transport.lookup(message.getValueString("idempotencyKey"));
if(delivery.isEmpty())
{
continue;
}
CrmEmailTransport.Delivery state = delivery.get();
QRecord change = new QRecord().withValue("id", lastId).withValue("status", state.status().getId());
date(change, "sentDate", state.sentDate());
date(change, "deliveredDate", state.deliveredDate());
date(change, "firstOpenedDate", state.openedDate());
date(change, "firstClickedDate", state.clickedDate());
date(change, "bouncedDate", state.bouncedDate());
boolean complaint = state.status() == CrmEmailStatus.COMPLAINED;
if(complaint || state.status() == CrmEmailStatus.BOUNCED)
{
Integer contactId = message.getValueInteger("contactId");
if(contactId != null)
{
CrmRecords.update("crm_contact", new QRecord().withValue("id", contactId).withValue("doNotEmail", true));
String source = complaint ? "Complaint" : "Hard bounce";
// The terminal message checkpoint is written last. A retry must not duplicate consent evidence.
String evidence = "Email: " + message.getValueString("idempotencyKey");
List<QRecord> recorded = new QueryAction().execute(new QueryInput("crm_consent_record").withFilter(new QQueryFilter(
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId),
new QFilterCriteria("source", QCriteriaOperator.EQUALS, source),
new QFilterCriteria("consentText", QCriteriaOperator.EQUALS, evidence)))).getRecords();
if(recorded.isEmpty())
{
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
.withValue("consentType", CrmConsentType.EMAIL.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
.withValue("consentDate", Instant.now()).withValue("source", source).withValue("consentText", evidence));
}
}
change.withValue("isSpamComplaint", complaint);
}
CrmRecords.update("crm_email_message", change);
updated++;
}
if(page.size() < 200)
{
break;
}
}
output.addValue("updatedCount", updated);
}
/*******************************************************************************
** Never clear a timestamp when the provider has not exposed it.
*******************************************************************************/
private void date(QRecord row, String field, Instant value)
{
if(value != null)
{
row.setValue(field, value);
}
}
}
@@ -1,222 +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.
*/
/*******************************************************************************
** Process to track email engagement events (open, click, bounce, spam, reply).
** Updates the EmailMessage record counters and flags, and may set doNotEmail
** on the linked contact for hard bounces and spam complaints.
*******************************************************************************/
package com.kingsrook.qbits.crm.email.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.email.model.EmailMessage;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep for tracking email engagement events.
** Handles OPEN, CLICK, BOUNCE, SPAM, and REPLY event types.
*******************************************************************************/
public class TrackEmailEventProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_track_email_event";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Track Email Event")
.withTableName(EmailMessage.TABLE_NAME)
.withIcon(new QIcon().withName("track_changes"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(TrackEmailEventProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("emailMessageId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("eventType", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("bounceTypeId", QFieldType.INTEGER)
)))
));
}
/*******************************************************************************
** Execute the tracking event process.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Integer emailMessageId = input.getValueInteger("emailMessageId");
String eventType = input.getValueString("eventType");
Integer bounceTypeId = input.getValueInteger("bounceTypeId");
//////////////////////////////////////////
// Step 1: load the email message //
//////////////////////////////////////////
QRecord emailRecord = GetAction.execute(EmailMessage.TABLE_NAME, emailMessageId);
if(emailRecord == null)
{
throw new QException("Email message not found: " + emailMessageId);
}
EmailMessage email = new EmailMessage(emailRecord);
//////////////////////////////////////////
// Step 2: handle event type //
//////////////////////////////////////////
QRecord updateRecord = new QRecord().withValue("id", emailMessageId);
switch(eventType.toUpperCase())
{
case "OPEN":
{
int currentOpenCount = email.getOpenCount() != null ? email.getOpenCount() : 0;
updateRecord.setValue("openCount", currentOpenCount + 1);
if(email.getFirstOpenedDate() == null)
{
updateRecord.setValue("firstOpenedDate", Instant.now());
}
break;
}
case "CLICK":
{
int currentClickCount = email.getClickCount() != null ? email.getClickCount() : 0;
updateRecord.setValue("clickCount", currentClickCount + 1);
if(email.getFirstClickedDate() == null)
{
updateRecord.setValue("firstClickedDate", Instant.now());
}
break;
}
case "BOUNCE":
{
updateRecord.setValue("bounceType", bounceTypeId);
/////////////////////////////////////////////////////
// if hard bounce, set contact doNotEmail to true //
/////////////////////////////////////////////////////
if(bounceTypeId != null && bounceTypeId.equals(CrmBounceType.HARD.getPossibleValueId()))
{
setContactDoNotEmail(email);
}
break;
}
case "SPAM":
{
updateRecord.setValue("isSpamComplaint", true);
setContactDoNotEmail(email);
break;
}
case "REPLY":
{
//////////////////////////////////////////////////////////////
// if email is linked to a sequence enrollment, mark REPLIED //
//////////////////////////////////////////////////////////////
if(email.getSequenceEnrollmentId() != null)
{
QRecord enrollmentUpdate = new QRecord()
.withValue("id", email.getSequenceEnrollmentId())
.withValue("status", CrmEnrollmentStatus.REPLIED.getPossibleValueId());
new UpdateAction().execute(
new UpdateInput("crm_sequence_enrollment").withRecord(enrollmentUpdate));
}
break;
}
default:
throw new QException("Unknown event type: " + eventType);
}
new UpdateAction().execute(
new UpdateInput(EmailMessage.TABLE_NAME).withRecord(updateRecord));
}
/***************************************************************************
** Finds the contact linked to an email message (via activity) and sets
** doNotEmail = true. Looks up by the toAddresses field.
***************************************************************************/
private void setContactDoNotEmail(EmailMessage email) throws QException
{
if(email.getToAddresses() == null)
{
return;
}
QueryOutput contactQuery = new QueryAction().execute(
new QueryInput(Contact.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("email", QCriteriaOperator.EQUALS, email.getToAddresses()))));
if(!contactQuery.getRecords().isEmpty())
{
QRecord contactUpdate = new QRecord()
.withValue("id", contactQuery.getRecords().get(0).getValueInteger("id"))
.withValue("doNotEmail", true);
new UpdateAction().execute(
new UpdateInput(Contact.TABLE_NAME).withRecord(contactUpdate));
}
}
}
@@ -27,7 +27,6 @@ package com.kingsrook.qbits.crm.email.processes;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
@@ -41,49 +40,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** MetaDataProducer + BackendStep to unenroll a contact from an email sequence.
** Backend operation used by the streamed CRM actions to unenroll a contact from an email sequence.
** Sets status to UNENROLLED and records the unenrolled date.
*******************************************************************************/
public class UnenrollFromSequenceProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class UnenrollFromSequenceProcess implements BackendStep
{
public static final String NAME = "crm_unenroll_from_sequence";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Unenroll from Sequence")
.withIcon(new QIcon().withName("playlist_remove"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(UnenrollFromSequenceProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("enrollmentId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the unenrollment logic.
*******************************************************************************/
@@ -0,0 +1,227 @@
/*
* 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.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;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
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.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;
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;
/*******************************************************************************
** 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.
*******************************************************************************/
public CrmHealthChecks(CrmQBitConfig config)
{
this(new CrmEmailTransport(config));
}
/*******************************************************************************
** Allow a host transport adapter.
*******************************************************************************/
public CrmHealthChecks(CrmEmailTransport transport)
{
this.transport = transport;
}
/*******************************************************************************
** 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);
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))
{
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)
{
try
{
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"));
}
}
});
}
/*******************************************************************************
** 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 HealthCheckOutcome email() throws QException
{
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 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 (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 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 (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"));
}
}
@@ -0,0 +1,247 @@
/*
* 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.privacy;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Erases the CRM's copies of a contact's personal data using tenant-scoped actions.
*******************************************************************************/
public final class CrmPersonalData
{
/*******************************************************************************
** Narrow consent exception: only replacing evidence text with "erased".
*******************************************************************************/
public enum ErasureFlag implements ActionFlag
{
REDACT_CONSENT
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmPersonalData()
{
}
/*******************************************************************************
** Erase linked data before the contact so retries still have the original email.
*******************************************************************************/
public static void erase(Integer contactId) throws QException
{
QRecord contact = GetAction.execute("crm_contact", contactId);
if(contact == null)
{
throw (new QException("Contact not found: " + contactId));
}
List<Serializable> emails = new ArrayList<>();
for(String field : List.of("email", "secondaryEmail"))
{
if(contact.getValueString(field) != null)
{
emails.add(contact.getValueString(field));
}
}
// Two queries avoid OR filters crossing tenant scopes and make retries idempotent.
eraseForms(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)));
if(!emails.isEmpty())
{
eraseForms(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.IN, emails)));
}
for(QRecord row : linked("crm_email_list_member", contactId))
{
redact("crm_email_list_member", row, "email", "firstName", "confirmationTokenHash", "consentText");
}
for(QRecord row : linked("crm_email_message", contactId))
{
redact("crm_email_message", row, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText");
}
for(QRecord row : linked("crm_activity", contactId))
{
redact("crm_activity", row, "description", "subject");
for(QRecord message : query("crm_email_message", new QQueryFilter(new QFilterCriteria("activityId", QCriteriaOperator.EQUALS, row.getValue("id")))))
{
redact("crm_email_message", message, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText");
}
}
for(QRecord row : linked("crm_consent_record", contactId))
{
checkedUpdate("crm_consent_record", new QRecord().withValue("id", row.getValue("id")).withValue("consentText", "erased"));
redactAudit("crm_consent_record", row.getValue("id"));
}
for(QRecord row : linked("crm_sequence_enrollment", contactId))
{
checkedUpdate("crm_sequence_enrollment", new QRecord().withValue("id", row.getValue("id"))
.withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()).withValue("nextStepDate", null));
redactAudit("crm_sequence_enrollment", row.getValue("id"));
}
for(QRecord row : query("crm_external_mapping", new QQueryFilter(
new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getId()),
new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId))))
{
var deleted = new DeleteAction().execute(new DeleteInput("crm_external_mapping").withPrimaryKey(row.getValue("id")).withOmitDmlAudit(true));
if(deleted.getDeletedRecordCount() != 1)
{
throw (new QException("CRM erase could not delete an external mapping"));
}
redactAudit("crm_external_mapping", row.getValue("id"));
}
redact("crm_contact", contact, "firstName", "lastName", "phone", "mobilePhone", "secondaryEmail", "addressLine1", "addressLine2",
"city", "state", "postalCode", "country", "timezone", "jobTitle", "department", "website", "linkedinUrl", "description");
checkedUpdate("crm_contact", new QRecord().withValue("id", contactId).withValue("email", "anonymized-" + contactId + "@removed.invalid")
.withValue("doNotEmail", true).withValue("doNotCall", true));
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
.withValue("consentType", CrmConsentType.DATA_PROCESSING.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
.withValue("consentText", "Erased on " + LocalDate.now(ZoneOffset.UTC)).withValue("source", "GDPR erasure").withValue("consentDate", Instant.now()));
}
/*******************************************************************************
** Erase both direct and email-matched web leads.
*******************************************************************************/
private static void eraseForms(QQueryFilter filter) throws QException
{
for(QRecord row : query("crm_form_submission", filter))
{
redact("crm_form_submission", row, "firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company");
}
}
/*******************************************************************************
** Collect rows through core record locks, never through a backend bypass.
*******************************************************************************/
private static List<QRecord> linked(String table, Integer contactId) throws QException
{
return (query(table, new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))));
}
/*******************************************************************************
** Page queries to avoid the backend's default result limit silently omitting PII.
*******************************************************************************/
private static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
List<QRecord> result = new ArrayList<>();
Serializable after = null;
while(true)
{
QQueryFilter page = filter.clone().withOrderBy(new QFilterOrderBy("id")).withLimit(250);
if(after != null)
{
page.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after));
}
List<QRecord> rows = new QueryAction().execute(new QueryInput(table).withFilter(page)).getRecords();
result.addAll(rows);
if(rows.size() < 250)
{
return (result);
}
after = rows.getLast().getValue("id");
}
}
/*******************************************************************************
** Keep required fields nonempty; never audit the original values during erasure.
*******************************************************************************/
private static void redact(String table, QRecord row, String... fields) throws QException
{
QRecord update = new QRecord().withValue("id", row.getValue("id"));
for(String field : fields)
{
boolean required = Boolean.TRUE.equals(QContext.getQInstance().getTable(table).getField(field).getIsRequired());
update.withValue(field, required || field.equals("firstName") || field.equals("lastName") ? "[ANONYMIZED]" : null);
}
checkedUpdate(table, update);
redactAudit(table, row.getValue("id"));
}
/*******************************************************************************
** A failed write must fail erasure; never report a partially erased contact as done.
*******************************************************************************/
private static void checkedUpdate(String table, QRecord update) throws QException
{
var output = new UpdateAction().execute(new UpdateInput(table).withRecord(update).withOmitDmlAudit(true).withFlag(ErasureFlag.REDACT_CONSENT));
if(output.getRecords().isEmpty() || output.getRecords().stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
{
throw (new QException("CRM erase could not redact " + table));
}
}
/*******************************************************************************
** RC.6 core audits resolve table ids through auditTable. Redact both values and
** human-readable messages, which can embed old record labels and personal values.
*******************************************************************************/
private static void redactAudit(String table, Serializable recordId) throws QException
{
if(QContext.getQInstance().getTable("auditTable") == null || QContext.getQInstance().getTable("audit") == null
|| QContext.getQInstance().getTable("auditDetail") == null)
{
return;
}
for(QRecord auditTable : query("auditTable", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, table))))
{
for(QRecord audit : query("audit", new QQueryFilter(
new QFilterCriteria("auditTableId", QCriteriaOperator.EQUALS, auditTable.getValue("id")),
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, recordId))))
{
for(QRecord detail : query("auditDetail", new QQueryFilter(new QFilterCriteria("auditId", QCriteriaOperator.EQUALS, audit.getValue("id")))))
{
checkedUpdate("auditDetail", new QRecord().withValue("id", detail.getValue("id"))
.withValue("oldValue", "erased").withValue("newValue", "erased").withValue("message", "erased"));
}
checkedUpdate("audit", new QRecord().withValue("id", audit.getValue("id")).withValue("message", "Personal data erased"));
}
}
}
}
@@ -0,0 +1,65 @@
/*
* 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.scoring;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Apply assignment rules to new contacts and deals without replacing explicit owners.
*******************************************************************************/
public class AutoAssignCustomizer extends AbstractPostInsertCustomizer
{
@Override
public List<QRecord> apply(List<QRecord> records) throws QException
{
for(QRecord row : records)
{
if(row.getErrors() != null && !row.getErrors().isEmpty())
{
continue;
}
String owner = row.getValueString("ownerUserId");
if(owner == null || "system".equals(owner))
{
CrmAutoAssign.Result result = CrmAutoAssign.assign("crm_contact".equals(insertInput.getTableName())
? CrmAssignableEntity.CONTACT : CrmAssignableEntity.DEAL, row.getValueInteger("id"));
if("ASSIGNED".equals(result.action()))
{
row.setValue("ownerUserId", result.assignedUserId());
QAlerts.notify(new NotificationSignal("CRM lead assigned").withSourceName("crm.lead.assigned")
.withSourceKey(insertInput.getTableName() + ":" + row.getValue("id"))
.withSourceTableName(insertInput.getTableName()).withSourceRecordId(row.getValueString("id"))
.withAudience(NotificationAudience.user(result.assignedUserId())), insertInput.getTransaction());
}
}
}
return (records);
}
}
@@ -0,0 +1,49 @@
/*
* 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.scoring;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Small persistent maintenance checkpoints, including the last full score pass.
*******************************************************************************/
public class JobState implements MetaDataProducerInterface<QTableMetaData>
{
public static final String TABLE_NAME = "crm_job_state";
@Override
public QTableMetaData produce(QInstance instance)
{
return (new QTableMetaData().withName(TABLE_NAME).withLabel("Job State").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true).withMaxLength(250))
.withField(new QFieldMetaData("lastSuccess", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("lastFullPass", QFieldType.DATE_TIME))
.withUniqueKey(new UniqueKey("name")));
}
}
@@ -26,22 +26,12 @@
package com.kingsrook.qbits.crm.scoring.processes;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qbits.crm.scoring.CrmAutoAssign;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
@@ -49,35 +39,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
** based on configured assignment rules. Evaluates rules in sortOrder; first
** match wins. Supports SPECIFIC_USER, ROUND_ROBIN, and LEAST_ACTIVE strategies.
*******************************************************************************/
public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
public class AutoAssignProcess implements BackendStep
{
public static final String NAME = "crm_auto_assign";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Auto Assign")
.withIcon(new QIcon().withName("assignment_ind"))
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(AutoAssignProcess.class))
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("recordId", QFieldType.INTEGER).withIsRequired(true)
)))
));
}
/*******************************************************************************
** Execute the assignment logic.
*******************************************************************************/
@@ -26,22 +26,29 @@
package com.kingsrook.qbits.crm.scoring.processes;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qbits.crm.scoring.model.LeadScoreRule;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
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;
@@ -80,9 +87,9 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
{
return new QProcessMetaData()
.withName(NAME)
.withIsHidden(true)
.withLabel("Calculate Lead Scores")
.withIcon(new QIcon().withName("score"))
// Scheduling is configured by the host application via ScheduledJob records
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
@@ -98,6 +105,18 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
////////////////////////////////////////////////////////////////////////
// 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();
Instant lastSuccess = state.getValueInstant("lastSuccess");
Instant lastFullPass = state.getValueInstant("lastFullPass");
boolean full = Boolean.TRUE.equals(input.getValueBoolean("fullRecalc")) || lastSuccess == null || lastFullPass == null
|| !lastFullPass.isAfter(startedAt.minus(24, ChronoUnit.HOURS));
///////////////////////////////////////////////
// load all active rules ordered by sort //
///////////////////////////////////////////////
@@ -114,7 +133,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
///////////////////////////////////////////////////////////////
// paginate through all contacts to avoid OOM on large CRMs //
///////////////////////////////////////////////////////////////
int pageSize = 500;
int pageSize = 200;
int updatedCount = 0;
int totalContacts = 0;
Integer lastId = null;
@@ -130,6 +149,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
if(!full)
{
filter.withCriteria(new QFilterCriteria("modifyDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, lastSuccess));
}
QueryOutput contactsQuery = new QueryAction().execute(
new QueryInput(Contact.TABLE_NAME).withFilter(filter));
@@ -138,41 +161,20 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
break;
}
Map<Integer, QRecord> companies = loadCompanies(contactsQuery.getRecords());
List<QRecord> changes = new ArrayList<>();
for(QRecord contactRecord : contactsQuery.getRecords())
{
Contact contact = new Contact(contactRecord);
totalContacts++;
int newScore = 0;
///////////////////////////////////////////////
// evaluate each rule against the contact //
///////////////////////////////////////////////
for(LeadScoreRule rule : rules)
QRecord change = calculateChange(contactRecord, rules, companies);
if(change != null)
{
if(evaluateRule(rule, contactRecord))
{
newScore += rule.getScoreAdjustment();
}
changes.add(change);
}
///////////////////////////////////////////////
// update only if score changed //
///////////////////////////////////////////////
Integer currentScore = contact.getLeadScore() != null ? contact.getLeadScore() : 0;
if(!Objects.equals(currentScore, newScore))
{
QRecord updateRecord = new QRecord()
.withValue("id", contact.getId())
.withValue("leadScore", newScore);
new UpdateAction().execute(
new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord));
updatedCount++;
}
lastId = contact.getId();
lastId = contactRecord.getValueInteger("id");
}
writeScores(changes);
updatedCount += changes.size();
/////////////////////////////////////////////
// if page was not full, we are done //
@@ -183,6 +185,21 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
}
}
CrmDealNotifications.checkRotting();
state.withValue("lastSuccess", startedAt);
if(full)
{
state.withValue("lastFullPass", startedAt);
}
if(states.isEmpty())
{
CrmRecords.insert(JobState.TABLE_NAME, state);
}
else
{
CrmRecords.update(JobState.TABLE_NAME, state);
}
output.addValue("fullRecalc", full);
output.addValue("updatedCount", updatedCount);
output.addValue("totalContacts", totalContacts);
output.addValue("totalRules", rules.size());
@@ -190,11 +207,63 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
/*******************************************************************************
** Recalculate only the selected contact using the same rules as the job.
*******************************************************************************/
public void recalculate(QRecord contact) throws QException
{
List<LeadScoreRule> rules = new QueryAction().execute(new QueryInput(LeadScoreRule.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
.withOrderBy(new QFilterOrderBy("sortOrder")))).getRecords().stream().map(LeadScoreRule::new).toList();
QRecord change = calculateChange(contact, rules, loadCompanies(List.of(contact)));
writeScores(change == null ? List.of() : List.of(change));
}
/*******************************************************************************
** Compute a score update only when the result changed.
*******************************************************************************/
private QRecord calculateChange(QRecord contact, List<LeadScoreRule> rules, Map<Integer, QRecord> companies) throws QException
{
int score = 0;
for(LeadScoreRule rule : rules)
{
if(evaluateRule(rule, contact, companies))
{
score += rule.getScoreAdjustment();
}
}
Integer current = contact.getValueInteger("leadScore");
if(Objects.equals(current == null ? 0 : current, score))
{
return (null);
}
return (new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score));
}
/*******************************************************************************
** Batch score writes so core audit label resolution also operates per page.
*******************************************************************************/
private void writeScores(List<QRecord> changes) throws QException
{
if(changes.isEmpty())
{
return;
}
UpdateInput update = new UpdateInput(Contact.TABLE_NAME).withOmitModifyDateUpdate(true);
update.setRecords(changes);
var updated = new UpdateAction().execute(update).getRecords();
if(updated.isEmpty() || updated.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
{
throw (new QException("Lead score update was rejected"));
}
}
/***************************************************************************
** Evaluate a single rule against a contact record. Supports dot-notation
** field paths for related entities (e.g., "company.industry").
***************************************************************************/
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord)
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord, Map<Integer, QRecord> companies)
{
CrmScoreOperator operator = CrmScoreOperator.getById(rule.getOperator());
if(operator == null)
@@ -203,7 +272,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
}
String fieldPath = rule.getFieldPath();
String actualValue = resolveFieldValue(fieldPath, contactRecord);
String actualValue = resolveFieldValue(fieldPath, contactRecord, companies);
String expectedValue = rule.getFieldValue();
switch(operator)
@@ -223,6 +292,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
case LT:
return (compareNumeric(actualValue, expectedValue) < 0);
case DAYS_AGO_GT:
case DAYS_AGO_LT:
return (compareAge(actualValue, expectedValue, operator));
case IS_SET:
return (StringUtils.hasContent(actualValue));
@@ -262,48 +335,54 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
** for related entity fields (e.g., "company.industry" loads the contact's
** company record and returns its "industry" field).
***************************************************************************/
private String resolveFieldValue(String fieldPath, QRecord contactRecord)
private String resolveFieldValue(String fieldPath, QRecord contactRecord, Map<Integer, QRecord> companies)
{
if(fieldPath == null || !fieldPath.contains("."))
{
return (contactRecord.getValueString(fieldPath));
}
String[] parts = fieldPath.split("\\.", 2);
String prefix = parts[0];
String fieldName = parts[1];
try
if("company".equalsIgnoreCase(parts[0]))
{
if("company".equalsIgnoreCase(prefix))
{
Integer companyId = contactRecord.getValueInteger("companyId");
if(companyId == null)
{
return (null);
}
GetOutput companyOutput = new GetAction().execute(
new GetInput(Company.TABLE_NAME).withPrimaryKey(companyId));
if(companyOutput.getRecord() == null)
{
return (null);
}
return (companyOutput.getRecord().getValueString(fieldName));
}
else
{
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + prefix);
return (null);
}
}
catch(Exception e)
{
LOG.warn("Error resolving dot-notation field [" + fieldPath + "] for contact", e);
return (null);
QRecord company = companies.get(contactRecord.getValueInteger("companyId"));
return (company == null ? null : company.getValueString(parts[1]));
}
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + parts[0]);
return (null);
}
/*******************************************************************************
** One company query per page, shared by every rule and contact in that page.
*******************************************************************************/
private Map<Integer, QRecord> loadCompanies(List<QRecord> contacts) throws QException
{
List<Integer> ids = contacts.stream().map(row -> row.getValueInteger("companyId")).filter(Objects::nonNull).distinct().toList();
Map<Integer, QRecord> companies = new HashMap<>();
if(!ids.isEmpty())
{
for(QRecord company : new QueryAction().execute(new QueryInput(Company.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)))).getRecords())
{
companies.put(company.getValueInteger("id"), company);
}
}
return (companies);
}
/*******************************************************************************
** Compare instants or UTC calendar dates with the strict N-days-ago boundary.
*******************************************************************************/
private boolean compareAge(String actual, String expected, CrmScoreOperator operator)
{
try
{
Instant date = actual.length() == 10 ? LocalDate.parse(actual).atStartOfDay(ZoneOffset.UTC).toInstant() : Instant.parse(actual);
Instant threshold = Instant.now().minus(Long.parseLong(expected), ChronoUnit.DAYS);
return (operator == CrmScoreOperator.DAYS_AGO_GT ? date.isBefore(threshold) : date.isAfter(threshold));
}
catch(RuntimeException e)
{
return (false);
}
}
}
@@ -0,0 +1,137 @@
/*
* 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.security;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.CrmAppMetaData;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** CRM field classification and optional tenant record locks.
*******************************************************************************/
public final class CrmDataPolicy
{
private static final Map<String, List<String>> PERSONAL_FIELDS = Map.of(
"crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1",
"addressLine2", "city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"),
"crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"),
"crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"),
"crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"),
"crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"),
"crm_consent_record", List.of("consentText"),
"crm_activity", List.of("description"));
private static final Map<String, String> PARENTS = Map.ofEntries(
Map.entry("crm_pipeline_stage", "crm_pipeline"),
Map.entry("crm_assignment_rule_member", "crm_assignment_rule"),
Map.entry("crm_consent_record", "crm_contact"),
Map.entry("crm_email_list_member", "crm_contact"),
Map.entry("crm_email_message", "crm_contact"),
Map.entry("crm_sequence_enrollment", "crm_contact"),
Map.entry("crm_sequence_step", "crm_email_sequence"),
Map.entry("crm_activity_participant", "crm_activity"),
Map.entry("crm_deal_contact", "crm_deal"),
Map.entry("crm_deal_product", "crm_deal"),
Map.entry("crm_deal_stage_history", "crm_deal"));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmDataPolicy()
{
}
/*******************************************************************************
** Apply after joins have their final names, before backend name inference.
*******************************************************************************/
public static void apply(MetaDataProducerMultiOutput output, QInstance instance, CrmQBitConfig config)
{
String keyType = config.getTenantSecurityKeyType();
if(keyType != null && instance.getSecurityKeyType(keyType) == null)
{
output.add(new QSecurityKeyType().withName(keyType).withAllAccessKeyName(config.getTenantAllAccessKeyName()));
}
for(QTableMetaData table : output.getEach(QTableMetaData.class))
{
for(String name : PERSONAL_FIELDS.getOrDefault(table.getName(), List.of()))
{
QFieldMetaData field = table.getField(name);
if(field == null)
{
throw (new IllegalStateException("Unknown CRM PII field: " + table.getName() + "." + name));
}
AiFieldMetaData ai = AiFieldMetaData.of(field);
field.withSupplementalMetaData((ai == null ? new AiFieldMetaData() : ai).withClassification(DataClassification.PII));
}
if(keyType == null)
{
continue;
}
RecordSecurityLock lock = new RecordSecurityLock().withSecurityKeyType(keyType);
String parent = PARENTS.get(table.getName());
if(parent == null)
{
// Polymorphic external mappings and internal job checkpoints need a direct key too.
table.withField(new QFieldMetaData("tenantKey", QFieldType.STRING).withMaxLength(250));
if(table.getSections() != null && !table.getSections().isEmpty())
{
table.withSection(new QFieldSection("crm_tenancy", "Tenancy", new QIcon("lock"), Tier.T3, List.of("tenantKey")));
}
lock.withFieldName("tenantKey");
}
else
{
QJoinMetaData join = output.getEach(QJoinMetaData.class).stream()
.filter(candidate -> parent.equals(candidate.getLeftTable()) && table.getName().equals(candidate.getRightTable()))
.findFirst().orElseThrow(() -> new IllegalStateException("Missing tenant parent join for " + table.getName()));
lock.withFieldName(parent + ".tenantKey").withJoinNameChain(List.of(join.getName()));
}
table.withRecordSecurityLock(lock);
}
}
/*******************************************************************************
** Main and settings tables exposed through the host API.
*******************************************************************************/
public static boolean isPublishedTable(String name)
{
return (CrmAppMetaData.MAIN_TABLES.contains(name) || CrmAppMetaData.SETTINGS_TABLES.contains(name));
}
}
@@ -49,7 +49,7 @@ public final class CrmPermissions
public static final List<String> ALL = List.of(APP_ACCESS, READ, WRITE, MANAGE, MANAGE_READ, MANAGE_WRITE, ADMIN, ADMIN_READ, ADMIN_WRITE);
private static final Set<String> SETTINGS_TABLES = Set.of(
"crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
"crm_job_state", "crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
"crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_lifecycle_stage",
"crm_lead_source", "crm_industry", "crm_activity_type", "crm_activity_outcome",
"crm_contact_role", "crm_win_loss_reason");
@@ -57,7 +57,8 @@ public final class CrmPermissions
private static final Set<String> MANAGEMENT_PROCESSES = Set.of(
"crm_merge_contacts", "crm_merge_companies", "crm_bulk_assign_owner",
"crm_subscribe_to_list", "crm_unsubscribe_from_list", "crm_enroll_in_sequence",
"crm_unenroll_from_sequence", "crm_process_sequence_steps");
"crm_unenroll_from_sequence", "crm_process_sequence_steps", "crm_assign_company_owner",
"crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence", "crm_sync_email_delivery", "crm_expire_consents", "crm_calculate_lead_scores");
@@ -0,0 +1,86 @@
/*
* 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.security;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** CRM relationship invariants, enforced for inserts and partial updates.
*******************************************************************************/
public class CrmRelationshipValidator implements TableCustomizerInterface
{
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview,
Optional<List<QRecord>> oldRecords) throws QException
{
Map<Object, QRecord> oldById = new HashMap<>();
oldRecords.orElse(List.of()).forEach(row -> oldById.put(row.getValue("id"), row));
for(QRecord record : records)
{
QRecord effective = new QRecord();
QRecord old = oldById.get(record.getValue("id"));
if(old != null)
{
effective.getValues().putAll(old.getValues());
}
effective.getValues().putAll(record.getValues());
if(effective.getValues().containsKey("pipelineStageId"))
{
validate(record, effective, "pipelineStageId", "pipelineId", "crm_pipeline_stage");
}
if(effective.getValues().containsKey("outcomeId"))
{
validate(record, effective, "outcomeId", "activityTypeId", "crm_activity_outcome");
}
}
return (records);
}
/*******************************************************************************
** Missing optional choices are allowed; populated choices must belong to the parent.
*******************************************************************************/
private void validate(QRecord record, QRecord effective, String childField, String parentField, String source) throws QException
{
if(effective.getValue(childField) == null)
{
return;
}
QRecord choice = GetAction.execute(source, effective.getValue(childField));
if(choice == null || effective.getValue(parentField) == null
|| !Objects.equals(choice.getValueInteger(parentField), effective.getValueInteger(parentField)))
{
record.addError(new BadInputStatusMessage("Choose a value belonging to the selected " + parentField + "."));
}
}
}
@@ -1,128 +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.
*/
/*******************************************************************************
** Renderer for the CRM Activity Count widget.
**
** Queries activities created today and groups them by activityTypeId to
** produce counts. Returns a MultiStatisticsData with one entry per known
** type: Call, Email, Meeting.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Renders activity counts by type for today as a multi-statistics widget.
*******************************************************************************/
public class CrmActivityCountRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
////////////////////////////////////////////////////////
// determine start-of-day in the system's time zone //
////////////////////////////////////////////////////////
ZoneId zoneId = ZoneId.systemDefault();
ZonedDateTime startOfDay = LocalDate.now(zoneId).atStartOfDay(zoneId);
Instant todayStart = startOfDay.toInstant();
////////////////////////////////////////////////////////////////
// query all activity types for labeling //
////////////////////////////////////////////////////////////////
Map<Integer, String> typeNameMap = new HashMap<>();
QueryOutput typeOutput = new QueryAction().execute(
new QueryInput(ActivityType.TABLE_NAME));
for(QRecord typeRecord : typeOutput.getRecords())
{
typeNameMap.put(typeRecord.getValueInteger("id"), typeRecord.getValueString("name"));
}
////////////////////////////////////////////////////////////
// query activities created today //
////////////////////////////////////////////////////////////
QueryOutput activityOutput = new QueryAction().execute(
new QueryInput(Activity.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, todayStart))));
////////////////////////////////////////////////////////////
// count by activityTypeId //
////////////////////////////////////////////////////////////
Map<Integer, Integer> countByType = new HashMap<>();
for(QRecord record : activityOutput.getRecords())
{
Integer typeId = record.getValueInteger("activityTypeId");
if(typeId != null)
{
countByType.merge(typeId, 1, Integer::sum);
}
}
////////////////////////////////////////////////////////////
// build statistics group data //
////////////////////////////////////////////////////////////
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
for(Map.Entry<Integer, Integer> entry : countByType.entrySet())
{
String typeName = typeNameMap.getOrDefault(entry.getKey(), "Type " + entry.getKey());
groups.add(new MultiStatisticsData.StatisticsGroupData()
.withHeader(typeName)
.withSubheader("today")
.withStatisticList(List.of(
new MultiStatisticsData.StatisticsGroupData.Statistic(typeName, entry.getValue(), null)
)));
}
MultiStatisticsData data = new MultiStatisticsData("Activity Counts (Today)", groups);
return (new RenderWidgetOutput(data));
}
}
@@ -1,68 +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.
*/
/*******************************************************************************
** MetaData producer for the CRM Activity Count widget.
**
** Provides a MULTI_STATISTICS view showing counts of activities by type for
** the current user today: calls, emails, meetings.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
/*******************************************************************************
** Produces metadata for the crm_activity_count widget (MULTI_STATISTICS type).
*******************************************************************************/
public class CrmActivityCountWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_activity_count";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.MULTI_STATISTICS.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Activity Counts (Today)")
.withTooltip("Activity counts by type for today")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new QIcon("analytics"))
.withCodeReference(new QCodeReference(CrmActivityCountRenderer.class));
}
}
@@ -1,131 +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.
*/
/*******************************************************************************
** Renderer for the CRM Activity Feed widget.
**
** Queries the most recent 50 activities (by createDate descending) and returns
** a table with columns: subject, activityType, contact, company, date.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders the activity feed as a table widget showing recent activities.
*******************************************************************************/
public class CrmActivityFeedRenderer extends AbstractWidgetRenderer
{
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter
.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneId.systemDefault());
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////
// query recent activities, ordered by createDate desc //
///////////////////////////////////////////////////////////
QueryInput queryInput = new QueryInput(Activity.TABLE_NAME);
queryInput.setFilter(new QQueryFilter()
.withOrderBy(new QFilterOrderBy("createDate", false))
.withLimit(50));
QueryOutput queryOutput = new QueryAction().execute(queryInput);
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Subject", "subject", "2fr", null),
new TableData.Column("html", "Activity Type", "activityType", "1fr", null),
new TableData.Column("html", "Contact", "contact", "1fr", null),
new TableData.Column("html", "Company", "company", "1fr", null),
new TableData.Column("html", "Date", "date", "1fr", "right")
);
//////////////////////////
// build table rows //
//////////////////////////
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord record : queryOutput.getRecords())
{
String subject = record.getValueString("subject");
if(subject == null)
{
subject = "";
}
Integer activityTypeId = record.getValueInteger("activityTypeId");
String activityTypeDisplay = activityTypeId != null ? String.valueOf(activityTypeId) : "-";
Integer contactId = record.getValueInteger("contactId");
String contactDisplay = contactId != null ? String.valueOf(contactId) : "-";
Integer companyId = record.getValueInteger("companyId");
String companyDisplay = companyId != null ? String.valueOf(companyId) : "-";
Instant createDate = record.getValueInstant("createDate");
String dateDisplay = createDate != null ? DATE_FORMATTER.format(createDate) : "-";
rows.add(MapBuilder.of(
"subject", subject,
"activityType", activityTypeDisplay,
"contact", contactDisplay,
"company", companyDisplay,
"date", dateDisplay
));
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(50)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,67 +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.
*/
/*******************************************************************************
** MetaData producer for the CRM Activity Feed widget.
**
** Provides a TABLE view showing the most recent 50 activities across the CRM
** with columns: subject, activityType, contact, company, date.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_activity_feed widget (TABLE type).
*******************************************************************************/
public class CrmActivityFeedWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_activity_feed";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Activity Feed")
.withTooltip("Recent CRM activities")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("event_note"))
.withCodeReference(new QCodeReference(CrmActivityFeedRenderer.class));
}
}
@@ -1,143 +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.
*/
/*******************************************************************************
** Renderer for the Average Deal Size widget.
**
** Queries deals where actualCloseDate is set and stage is closedWon.
** Computes average amountInBaseCurrency and returns as a single-stat table.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders average deal size as a single-stat table widget.
*******************************************************************************/
public class CrmAvgDealSizeRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////
// find closedWon stage IDs //
///////////////////////////////////////////////////////////
Set<Integer> closedWonStageIds = new HashSet<>();
QueryOutput stageOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
for(QRecord record : stageOutput.getRecords())
{
closedWonStageIds.add(record.getValueInteger("id"));
}
///////////////////////////////////////////////////////////
// query deals with actualCloseDate set //
///////////////////////////////////////////////////////////
QueryOutput dealOutput = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK))));
///////////////////////////////////////////////////////////
// compute average for closedWon deals //
///////////////////////////////////////////////////////////
BigDecimal totalAmount = BigDecimal.ZERO;
int count = 0;
for(QRecord record : dealOutput.getRecords())
{
Integer stageId = record.getValueInteger("pipelineStageId");
if(closedWonStageIds.contains(stageId))
{
BigDecimal amount = record.getValueBigDecimal("amountInBaseCurrency");
if(amount == null)
{
amount = record.getValueBigDecimal("amount");
}
if(amount != null)
{
totalAmount = totalAmount.add(amount);
count++;
}
}
}
BigDecimal avgDealSize = count > 0
? totalAmount.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
///////////////////////////
// build table output //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Metric", "metric", "1fr", null),
new TableData.Column("html", "Value", "value", "1fr", "right")
);
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(MapBuilder.of(
"metric", "Average Deal Size",
"value", "$" + avgDealSize.toPlainString()
));
rows.add(MapBuilder.of(
"metric", "Closed Won Deals",
"value", String.valueOf(count)
));
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(10)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,66 +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.
*/
/*******************************************************************************
** MetaData producer for the Average Deal Size widget.
**
** Shows the average amount of closed-won deals.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_avg_deal_size widget (TABLE type).
*******************************************************************************/
public class CrmAvgDealSizeWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_avg_deal_size";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(3)
.withIsCard(true)
.withLabel("Average Deal Size")
.withTooltip("Average amount of closed-won deals")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("bar_chart"))
.withCodeReference(new QCodeReference(CrmAvgDealSizeRenderer.class));
}
}
@@ -19,109 +19,99 @@
*/
/*******************************************************************************
** Renderer for the CRM Contact Timeline widget.
**
** Queries activities for a specific contactId (from widget input) and returns
** a StepperData with each activity as a chronological step. Activities are
** ordered by createDate ascending.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StepperData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Renders a contact's activity timeline as a stepper widget.
** A contact's latest fifty recorded events across activities, sent messages,
** web leads and consent. Each source is bounded before merging by create date.
*******************************************************************************/
public class CrmContactTimelineRenderer extends AbstractWidgetRenderer
{
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter
.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneId.systemDefault());
/*******************************************************************************
**
** Record sections supply id; contactId remains useful for embedded hosts.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String contactIdParam = input.getQueryParams().get("contactId");
if(!StringUtils.hasContent(contactIdParam))
List<Map<String, Object>> rows = new ArrayList<>();
String id = input.getQueryParams().getOrDefault("id", input.getQueryParams().get("contactId"));
if(id != null && !id.isBlank() && GetAction.execute("crm_contact", Integer.valueOf(id)) != null)
{
return (new RenderWidgetOutput(new StepperData("Contact Timeline", 0, List.of())));
}
Integer contactId = ValueUtils.getValueAsInteger(contactIdParam);
//////////////////////////////////////////////////////////////
// query activities for this contact, ordered chronologically //
//////////////////////////////////////////////////////////////
QueryInput queryInput = new QueryInput(Activity.TABLE_NAME);
queryInput.setFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))
.withOrderBy(new QFilterOrderBy("createDate", true)));
QueryOutput queryOutput = new QueryAction().execute(queryInput);
////////////////////////////////////
// build steps from activities //
////////////////////////////////////
List<StepperData.Step> steps = new ArrayList<>();
for(QRecord record : queryOutput.getRecords())
{
String subject = record.getValueString("subject");
if(subject == null)
List<QRecord> events = new ArrayList<>();
for(String table : List.of("crm_activity", "crm_email_message", "crm_form_submission", "crm_consent_record"))
{
subject = "Activity";
for(QRecord record : CrmHomeRenderer.query(table, CrmHomeRenderer.equalsFilter("contactId", Integer.valueOf(id)), "createDate", false, 50))
{
record.setValue("timelineTable", table);
events.add(record);
}
}
events.sort(Comparator.comparing((QRecord record) -> Objects.requireNonNullElse(record.getValueInstant("createDate"), Instant.EPOCH)).reversed()
.thenComparing(record -> record.getValueString("timelineTable")).thenComparing(record -> record.getValueInteger("id")));
for(QRecord record : events.stream().limit(50).toList())
{
String table = record.getValueString("timelineTable");
String kind = Map.of("crm_activity", "Activity", "crm_email_message", "Email message",
"crm_form_submission", "Web lead", "crm_consent_record", "Consent record").get(table);
String detail = Objects.toString(record.getValue("subject"), Objects.toString(record.getValue("email"), kind));
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), formatDate(record.getValueInstant("createDate")), kind, detail));
}
Instant createDate = record.getValueInstant("createDate");
String dateStr = createDate != null ? DATE_FORMATTER.format(createDate) : "";
String description = record.getValueString("description");
String linkText = StringUtils.hasContent(description)
? description.substring(0, Math.min(description.length(), 100))
: dateStr;
steps.add(new StepperData.Step()
.withLabel(subject)
.withLinkText(linkText));
}
//////////////////////////////////////////////////////////////
// set activeStep to the last step (most recent activity) //
//////////////////////////////////////////////////////////////
Integer activeStep = steps.isEmpty() ? 0 : steps.size() - 1;
StepperData stepperData = new StepperData("Contact Timeline", activeStep, steps);
return (new RenderWidgetOutput(stepperData));
return (new RenderWidgetOutput(CrmHomeRenderer.table(rows, "Recorded", "Kind", "Details")));
}
/*******************************************************************************
** Prefer the session timezone (including DST), then offset, otherwise UTC.
*******************************************************************************/
static String formatDate(Instant instant)
{
if(instant == null)
{
return ("");
}
ZoneId zone = ZoneOffset.UTC;
QSession session = QContext.getQSession();
String timezone = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE);
String offset = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES);
try
{
if(timezone != null && !timezone.isBlank())
{
zone = ZoneId.of(timezone);
}
else if(offset != null && !offset.isBlank())
{
zone = ZoneOffset.ofTotalSeconds(Math.multiplyExact(60, Integer.parseInt(offset)));
}
}
catch(DateTimeException | IllegalArgumentException | ArithmeticException e)
{
zone = ZoneOffset.UTC;
}
return (DateTimeFormatter.ofPattern("MMM d, uuuu h:mm a", Locale.US).withZone(zone).format(instant));
}
}
@@ -22,13 +22,14 @@
/*******************************************************************************
** MetaData producer for the CRM Contact Timeline widget.
**
** Provides a STEPPER view showing a chronological timeline of activities for
** a specific contact. The contactId is passed as a widget input parameter.
** Provides a standard table of activities, messages, web leads and consent for
** the contact selected by the record screen id parameter.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
@@ -39,7 +40,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
/*******************************************************************************
** Produces metadata for the crm_contact_timeline widget (STEPPER type).
** Produces metadata for the crm_contact_timeline widget (TABLE type).
*******************************************************************************/
public class CrmContactTimelineWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
@@ -55,8 +56,9 @@ public class CrmContactTimelineWidgetProducer extends MetaDataProducer<QWidgetMe
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.STEPPER.getType())
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
.withIsCard(true)
.withLabel("Contact Timeline")
.withTooltip("Chronological activity timeline for this contact")
@@ -1,189 +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.
*/
/*******************************************************************************
** Renderer for the CRM Deal Forecast widget.
**
** Queries open deals (not in closed stages) and groups them by expected close
** month. Returns a table with columns: month, dealCount, totalAmount,
** totalWeightedAmount.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders the deal forecast as a table widget showing open deal aggregates
** grouped by expected close month.
*******************************************************************************/
public class CrmDealForecastRenderer extends AbstractWidgetRenderer
{
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////////
// find all closed stage IDs to exclude //
///////////////////////////////////////////////////////////////
Set<Integer> closedStageIds = findClosedStageIds();
///////////////////////////////////////////////////////////////
// query all deals that are NOT in closed stages //
///////////////////////////////////////////////////////////////
QQueryFilter filter = new QQueryFilter();
if(!closedStageIds.isEmpty())
{
filter.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.NOT_IN, new ArrayList<>(closedStageIds)));
}
QueryOutput dealOutput = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME).withFilter(filter));
//////////////////////////////////////////////////////////
// aggregate deals by expected close month //
//////////////////////////////////////////////////////////
Map<YearMonth, Integer> countByMonth = new TreeMap<>();
Map<YearMonth, BigDecimal> amountByMonth = new TreeMap<>();
Map<YearMonth, BigDecimal> weightedByMonth = new TreeMap<>();
for(QRecord record : dealOutput.getRecords())
{
LocalDate expectedCloseDate = record.getValueLocalDate("expectedCloseDate");
if(expectedCloseDate == null)
{
continue;
}
YearMonth yearMonth = YearMonth.from(expectedCloseDate);
countByMonth.merge(yearMonth, 1, Integer::sum);
BigDecimal amount = record.getValueBigDecimal("amount");
if(amount != null)
{
amountByMonth.merge(yearMonth, amount, BigDecimal::add);
}
BigDecimal weighted = record.getValueBigDecimal("weightedAmount");
if(weighted != null)
{
weightedByMonth.merge(yearMonth, weighted, BigDecimal::add);
}
}
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Month", "month", "1fr", null),
new TableData.Column("html", "Deal Count", "dealCount", "1fr", "right"),
new TableData.Column("html", "Total Amount", "totalAmount", "1fr", "right"),
new TableData.Column("html", "Weighted Amount", "totalWeightedAmount", "1fr", "right")
);
//////////////////////////
// build table rows //
//////////////////////////
List<Map<String, Object>> rows = new ArrayList<>();
for(YearMonth yearMonth : countByMonth.keySet())
{
rows.add(MapBuilder.of(
"month", yearMonth.format(MONTH_FORMAT),
"dealCount", countByMonth.getOrDefault(yearMonth, 0),
"totalAmount", amountByMonth.getOrDefault(yearMonth, BigDecimal.ZERO).toPlainString(),
"totalWeightedAmount", weightedByMonth.getOrDefault(yearMonth, BigDecimal.ZERO).toPlainString()
));
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(50)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
/***************************************************************************
** Find all pipeline stage IDs that are closed (won or lost).
***************************************************************************/
private Set<Integer> findClosedStageIds() throws QException
{
QQueryFilter filter = new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true));
QQueryFilter lostFilter = new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.EQUALS, true));
///////////////////////////////////////////////////////////////////////////
// query stages that are closed won OR closed lost //
///////////////////////////////////////////////////////////////////////////
QueryOutput wonOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME).withFilter(filter));
QueryOutput lostOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME).withFilter(lostFilter));
Set<Integer> closedIds = wonOutput.getRecords().stream()
.map(r -> r.getValueInteger("id"))
.collect(Collectors.toSet());
lostOutput.getRecords().stream()
.map(r -> r.getValueInteger("id"))
.forEach(closedIds::add);
return (closedIds);
}
}
@@ -1,67 +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.
*/
/*******************************************************************************
** MetaData producer for the CRM Deal Forecast widget.
**
** Provides a TABLE view showing open deals grouped by expected close month
** with counts, total amounts, and weighted amounts.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_deal_forecast widget (TABLE type).
*******************************************************************************/
public class CrmDealForecastWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_deal_forecast";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Deal Forecast")
.withTooltip("Open deals grouped by expected close month")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("trending_up"))
.withCodeReference(new QCodeReference(CrmDealForecastRenderer.class));
}
}
@@ -0,0 +1,72 @@
/*
* 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 java.time.LocalDate;
import java.time.ZoneOffset;
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.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Hide Goals in per-session frontend metadata unless a current goal is visible.
** This is evaluated at request time, never while building shared app metadata.
*******************************************************************************/
public class CrmGoalsVisibility implements CustomPermissionChecker
{
/*******************************************************************************
** Preserve crm.hasAccess and check existence through a bounded scoped query.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS))
{
throw (new QPermissionDeniedException("CRM access is required."));
}
LocalDate today = LocalDate.now(ZoneOffset.UTC);
QQueryFilter filter = new QQueryFilter()
.withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today))
.withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today));
try
{
if(!CrmHomeRenderer.query("crm_sales_goal", filter, "id", true, 1).isEmpty())
{
return;
}
}
catch(QException e)
{
throw (new QPermissionDeniedException("Current goals are not available.", e));
}
throw (new QPermissionDeniedException("No goals for the current period."));
}
}
@@ -0,0 +1,538 @@
/*
* 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 java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
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.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ParentWidgetData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
/*******************************************************************************
** Daily CRM views. Core actions retain the current session and record locks.
** Monetary totals use base currency; weighted amounts are grouped by currency
** before conversion. No query loads all deals, contacts or submissions.
*******************************************************************************/
public class CrmHomeRenderer extends AbstractWidgetRenderer
{
private static final String DEAL = "crm_deal";
private static final String STAGE = "crm_pipeline_stage";
private static final Aggregate COUNT = new Aggregate("id", AggregateOperator.COUNT);
private static final Aggregate AMOUNT = new Aggregate("amountInBaseCurrency", AggregateOperator.SUM);
private static final Aggregate WEIGHTED = new Aggregate("weightedAmount", AggregateOperator.SUM);
private static final GroupBy CURRENCY = new GroupBy(QFieldType.STRING, "currencyCode");
/*******************************************************************************
** Render only the requested home component using UTC reporting boundaries.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
LocalDate today = LocalDate.now(ZoneOffset.UTC);
switch(input.getWidgetMetaData().getName())
{
case "crm_today":
return (new RenderWidgetOutput(new ParentWidgetData().withChildWidgetNameList(CrmHomeWidgets.TODAY).withLabel("Today")));
case "crm_setup":
return (new RenderWidgetOutput(setup()));
case "crm_open_pipeline":
return (new RenderWidgetOutput(statistic(sum(DEAL, dealStatus(false), AMOUNT), true)));
case "crm_won_month":
return (new RenderWidgetOutput(statistic(sum(DEAL, period(dealStatus(true), "actualCloseDate", today.withDayOfMonth(1), today.plusDays(1)), AMOUNT), true)));
case "crm_new_contacts":
return (new RenderWidgetOutput(statistic(countSince("crm_contact", 30), false)));
case "crm_new_web_leads":
return (new RenderWidgetOutput(statistic(countSince("crm_form_submission", 7), false)));
case "crm_overdue_activities":
return (new RenderWidgetOutput(overdue()));
case "crm_pipeline_summary":
return (new RenderWidgetOutput(pipeline(input)));
case "crm_deal_forecast":
return (new RenderWidgetOutput(forecast(today)));
case "crm_rotting_deals":
return (new RenderWidgetOutput(rotting()));
case "crm_recent_web_leads":
return (new RenderWidgetOutput(webLeads()));
case "crm_goals":
return (new RenderWidgetOutput(goals(today)));
case "crm_sales_leaderboard":
return (new RenderWidgetOutput(leaderboard(today)));
default:
throw new QException("Unknown CRM home widget: " + input.getWidgetMetaData().getName());
}
}
/*******************************************************************************
** Format statistics with the configured base currency in the value.
*******************************************************************************/
private StatisticsData statistic(BigDecimal value, boolean currency) throws QException
{
String prefix = "";
if(currency)
{
List<QRecord> base = query("crm_currency", equalsFilter("isBaseCurrency", true), "id", true, 1);
if(!base.isEmpty() && base.getFirst().getValueString("currencyCode") != null)
{
prefix = base.getFirst().getValueString("currencyCode") + " ";
}
}
return (new StatisticsData().withCount(prefix + formatAmount(value)));
}
/*******************************************************************************
** Rolling counts include the present instant and exclude future records.
*******************************************************************************/
private BigDecimal countSince(String table, int days) throws QException
{
Instant now = Instant.now();
return (sum(table, period(new QQueryFilter(), "createDate", now.minus(days, ChronoUnit.DAYS), now), COUNT));
}
/*******************************************************************************
** An unassigned activity is never treated as belonging to an anonymous user.
*******************************************************************************/
private StatisticsData overdue() throws QException
{
String user = QContext.getQSession().getIdReference();
if(user == null || user.isBlank())
{
return (statistic(BigDecimal.ZERO, false));
}
QQueryFilter filter = equalsFilter("assignedToUserId", user)
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
.withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now()));
return (statistic(sum("crm_activity", filter, COUNT), false));
}
/*******************************************************************************
** Open and won stages are resolved through the current session as well.
*******************************************************************************/
private QQueryFilter dealStatus(boolean won) throws QException
{
QQueryFilter filter = won ? equalsFilter("isClosedWon", true) : new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
.withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true));
List<Integer> ids = allStages(filter).stream().map(row -> row.getValueInteger("id")).toList();
return (new QQueryFilter().withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, ids)));
}
/*******************************************************************************
** Stage dictionaries are paged; transactional facts are aggregated in SQL.
*******************************************************************************/
private List<QRecord> allStages(QQueryFilter filter) throws QException
{
return (dictionary(STAGE, filter));
}
/*******************************************************************************
** Read small settings dictionaries in deterministic keyset pages.
*******************************************************************************/
private List<QRecord> dictionary(String table, QQueryFilter filter) throws QException
{
List<QRecord> stages = new ArrayList<>();
int lastId = 0;
while(true)
{
QQueryFilter page = filter.clone().withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
List<QRecord> records = query(table, page, "id", true, 200);
stages.addAll(records);
if(records.size() < 200)
{
return (stages);
}
lastId = records.getLast().getValueInteger("id");
}
}
/*******************************************************************************
** Separate the stage label, integer count and weighted base-currency amount.
*******************************************************************************/
private TableData pipeline(RenderWidgetInput input) throws QException
{
List<QRecord> pipelines = dictionary("crm_pipeline", equalsFilter("isActive", true));
List<Map<String, Object>> rows = new ArrayList<>();
String selected = pipelines.isEmpty() ? null : pipelines.getFirst().getValueString("id");
String requested = input.getQueryParams().get("pipelineId");
if(pipelines.stream().anyMatch(pipeline -> pipeline.getValueString("id").equals(requested)))
{
selected = requested;
}
if(!pipelines.isEmpty())
{
Integer pipelineId = Integer.valueOf(selected);
List<QRecord> stages = allStages(equalsFilter("pipelineId", pipelineId));
stages.sort(Comparator.comparing(row -> Objects.requireNonNullElse(row.getValueInteger("sortOrder"), 0)));
GroupBy stageGroup = new GroupBy(QFieldType.INTEGER, "pipelineStageId");
Map<Serializable, BigDecimal[]> totals = groupedTotals(equalsFilter("pipelineId", pipelineId), stageGroup);
for(QRecord stage : stages)
{
BigDecimal[] values = totals.getOrDefault(stage.getValue("id"), new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
String href = AiLinkBuilder.recordHref(STAGE, stage.getValue("id"));
rows.add(row(href, stage.getValueString("name"), values[0].toBigIntegerExact(), formatAmount(values[2])));
}
}
TableData data = table(rows, "Stage", "Deals", "Weighted amount");
if(pipelines.size() > 1)
{
data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline"))
.withDropdownDefaultValueList(List.of(selected))
.withDropdownDataList(List.of(pipelines.stream().map(pipeline -> Map.of("id", pipeline.getValueString("id"),
"label", Objects.toString(pipeline.getValue("name"), "Pipeline " + pipeline.getValueString("id")))).toList()));
}
return (data);
}
/*******************************************************************************
** The Setup card: each piece of configuration the CRM needs, its state and the
** next step, every cell linking to the table that fixes it.
*******************************************************************************/
private TableData setup() throws QException
{
List<Map<String, Object>> rows = new ArrayList<>();
for(CrmSetupStatus.Item item : CrmSetupStatus.load())
{
rows.add(row(AiLinkBuilder.filteredTableHref(item.tableName(), new QQueryFilter()), item.label(),
item.state().getLabel() + ": " + item.detail(), item.nextStep() == null ? "Nothing to do" : item.nextStep()));
}
return (table(rows, "Setup", "Status", "Next step"));
}
/*******************************************************************************
** Six calendar buckets, including the current month, with empty months kept.
*******************************************************************************/
private TableData forecast(LocalDate today) throws QException
{
List<Map<String, Object>> rows = new ArrayList<>();
for(int month = 0; month < 6; month++)
{
LocalDate start = today.withDayOfMonth(1).plusMonths(month);
QQueryFilter filter = period(dealStatus(false), "expectedCloseDate", start, start.plusMonths(1));
BigDecimal[] totals = totals(filter);
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0].toBigIntegerExact(), formatAmount(totals[2])));
}
return (table(rows, "Month", "Deals", "Weighted amount (base)"));
}
/*******************************************************************************
** Limit the candidates in each stage in SQL, then select the oldest ten.
*******************************************************************************/
private TableData rotting() throws QException
{
List<QRecord> candidates = new ArrayList<>();
Map<Integer, String> stages = new LinkedHashMap<>();
Instant now = Instant.now();
QQueryFilter stageFilter = new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
.withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
.withCriteria(new QFilterCriteria("rotDays", QCriteriaOperator.GREATER_THAN, 0));
for(QRecord stage : allStages(stageFilter))
{
Integer id = stage.getValueInteger("id");
stages.put(id, stage.getValueString("name"));
QQueryFilter filter = equalsFilter("pipelineStageId", id)
.withCriteria(new QFilterCriteria("stageEnteredDate", QCriteriaOperator.LESS_THAN, now.minus(stage.getValueInteger("rotDays"), ChronoUnit.DAYS)));
candidates.addAll(query(DEAL, filter, "stageEnteredDate", true, 10));
}
candidates.sort(Comparator.comparing(row -> row.getValueInstant("stageEnteredDate")));
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord deal : candidates.stream().limit(10).toList())
{
rows.add(row(AiLinkBuilder.recordHref(DEAL, deal.getValue("id")), deal.getValueString("name"),
stages.get(deal.getValueInteger("pipelineStageId")), ChronoUnit.DAYS.between(deal.getValueInstant("stageEnteredDate"), now)));
}
return (table(rows, "Deal", "Stage", "Days in stage"));
}
/*******************************************************************************
** Latest ten submissions, with escaped labels and links on every cell.
*******************************************************************************/
private TableData webLeads() throws QException
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord lead : query("crm_form_submission", new QQueryFilter(), "createDate", false, 10))
{
String name = (Objects.toString(lead.getValue("firstName"), "") + " " + Objects.toString(lead.getValue("lastName"), "")).trim();
if(Objects.toString(lead.getValue("firstName"), "").isBlank() || Objects.toString(lead.getValue("lastName"), "").isBlank())
{
name = Objects.toString(lead.getValue("email"), "Web lead");
}
String form = "Unknown";
for(CrmFormType value : CrmFormType.values())
{
if(value.getId().equals(lead.getValueInteger("formType")))
{
form = value.getLabel();
}
}
String status = "New";
for(CrmFormConversionStatus value : CrmFormConversionStatus.values())
{
if(value.getId().equals(lead.getValueInteger("conversionStatus")))
{
status = value.getLabel();
}
}
rows.add(row(AiLinkBuilder.recordHref("crm_form_submission", lead.getValue("id")), name, form, status));
}
return (table(rows, "Name or email", "Form", "Status"));
}
/*******************************************************************************
** Current goals only; metadata visibility hides the widget when none exist.
*******************************************************************************/
private TableData goals(LocalDate today) throws QException
{
QQueryFilter filter = new QQueryFilter()
.withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today))
.withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today));
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord goal : query("crm_sales_goal", filter, "id", true, 50))
{
QQueryFilter deals = period(dealStatus(true), "actualCloseDate", goal.getValueLocalDate("periodStart"), goal.getValueLocalDate("periodEnd").plusDays(1));
deals.withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, goal.getValue("userId")));
if(goal.getValue("pipelineId") != null)
{
deals.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, goal.getValue("pipelineId")));
}
BigDecimal actual = sum(DEAL, deals, AMOUNT);
BigDecimal target = CrmCurrencyUtils.convertToBaseCurrency(goal.getValueBigDecimal("targetAmount"), goal.getValueString("currencyCode"));
String attainment = target != null && target.signum() > 0 ? actual.multiply(BigDecimal.valueOf(100)).divide(target, 1, RoundingMode.HALF_UP) + "%" : "No target";
rows.add(row(AiLinkBuilder.recordHref("crm_sales_goal", goal.getValue("id")),
goal.getValueString("userId") + " (" + attainment + ")", formatAmount(target), formatAmount(actual)));
}
TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)");
return (data);
}
/*******************************************************************************
** Quarter-to-date won revenue grouped by owner, linking to matching deals.
*******************************************************************************/
private TableData leaderboard(LocalDate today) throws QException
{
LocalDate start = today.withMonth(((today.getMonthValue() - 1) / 3) * 3 + 1).withDayOfMonth(1);
QQueryFilter filter = period(dealStatus(true), "actualCloseDate", start, today.plusDays(1));
GroupBy owner = new GroupBy(QFieldType.STRING, "ownerUserId");
List<AggregateResult> results = aggregate(DEAL, filter, List.of(COUNT, AMOUNT), List.of(owner));
results.sort(Comparator.comparing((AggregateResult result) -> number(result.getAggregateValue(AMOUNT))).reversed());
List<Map<String, Object>> rows = new ArrayList<>();
for(AggregateResult result : results)
{
Serializable user = result.getGroupByValue(owner);
QQueryFilter ownerFilter = filter.clone().withCriteria(new QFilterCriteria("ownerUserId", user == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, user));
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, ownerFilter), Objects.toString(user, "Unassigned"),
number(result.getAggregateValue(COUNT)).toBigIntegerExact(), formatAmount(number(result.getAggregateValue(AMOUNT)))));
}
return (table(rows, "Owner", "Won deals", "Won amount (base)"));
}
/*******************************************************************************
** Aggregate currency groups instead of loading the underlying deals.
*******************************************************************************/
private Map<Serializable, BigDecimal[]> groupedTotals(QQueryFilter filter, GroupBy group) throws QException
{
List<GroupBy> groups = new ArrayList<>(List.of(CURRENCY));
if(group != null)
{
groups.add(group);
}
Map<Serializable, BigDecimal[]> totals = new LinkedHashMap<>();
for(AggregateResult result : aggregate(DEAL, filter, List.of(COUNT, AMOUNT, WEIGHTED), groups))
{
Serializable key = group == null ? "total" : result.getGroupByValue(group);
BigDecimal[] values = totals.computeIfAbsent(key, ignored -> new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
values[0] = values[0].add(number(result.getAggregateValue(COUNT)));
values[1] = values[1].add(number(result.getAggregateValue(AMOUNT)));
values[2] = values[2].add(CrmCurrencyUtils.convertToBaseCurrency(number(result.getAggregateValue(WEIGHTED)), (String) result.getGroupByValue(CURRENCY)));
}
return (totals);
}
/*******************************************************************************
** Totals across all currency buckets.
*******************************************************************************/
private BigDecimal[] totals(QQueryFilter filter) throws QException
{
return (groupedTotals(filter, null).getOrDefault("total", new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}));
}
/*******************************************************************************
** A scalar aggregate with an explicit zero for an empty selection.
*******************************************************************************/
private BigDecimal sum(String table, QQueryFilter filter, Aggregate aggregate) throws QException
{
List<AggregateResult> results = aggregate(table, filter, List.of(aggregate), List.of());
return (results.isEmpty() ? BigDecimal.ZERO : number(results.getFirst().getAggregateValue(aggregate)));
}
/*******************************************************************************
** Delegate all permission and record-lock filtering to the core action.
*******************************************************************************/
private List<AggregateResult> aggregate(String table, QQueryFilter filter, List<Aggregate> aggregates, List<GroupBy> groups) throws QException
{
return (new AggregateAction().execute(new AggregateInput(table).withFilter(filter).withAggregates(aggregates).withGroupBys(groups)).getResults());
}
/*******************************************************************************
** Normalize aggregate values without lossy integer conversions.
*******************************************************************************/
private BigDecimal number(Serializable value)
{
return (value == null ? BigDecimal.ZERO : new BigDecimal(value.toString()));
}
/*******************************************************************************
** A bounded query with deterministic ordering for tied timestamps.
*******************************************************************************/
static List<QRecord> query(String table, QQueryFilter filter, String order, boolean ascending, int limit) throws QException
{
return (new QueryAction().execute(new QueryInput(table).withFilter(filter.withOrderBy(new QFilterOrderBy(order, ascending))
.withOrderBy(new QFilterOrderBy("id", ascending)).withLimit(limit))).getRecords());
}
/*******************************************************************************
** Equality filter.
*******************************************************************************/
static QQueryFilter equalsFilter(String field, Serializable value)
{
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
}
/*******************************************************************************
** Half-open reporting interval, avoiding end-of-month time rounding.
*******************************************************************************/
private QQueryFilter period(QQueryFilter filter, String field, Serializable start, Serializable end)
{
return (filter.withCriteria(new QFilterCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, start))
.withCriteria(new QFilterCriteria(field, QCriteriaOperator.LESS_THAN, end)));
}
/*******************************************************************************
** Group amounts without losing fractional precision or padding whole amounts.
*******************************************************************************/
static String formatAmount(BigDecimal value)
{
BigDecimal amount = value == null ? BigDecimal.ZERO : value;
DecimalFormat format = new DecimalFormat("#,##0", DecimalFormatSymbols.getInstance(Locale.US));
format.setMaximumFractionDigits(Math.max(0, amount.scale()));
return (format.format(amount));
}
/*******************************************************************************
** Every cell navigates to its record, or to the records in an aggregate.
*******************************************************************************/
static Map<String, Object> row(String href, Object first, Object second, Object third)
{
return (Map.of("first", link(href, first), "second", link(href, second), "third", link(href, third)));
}
/*******************************************************************************
** Standard table cells may contain links; user text is always escaped.
*******************************************************************************/
private static String link(String href, Object label)
{
return ("<a href=\"" + escape(href) + "\">" + escape(Objects.toString(label, "")) + "</a>");
}
/*******************************************************************************
** Escape text and attributes in links.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** Three columns, full-width metadata, and the standard paginated table.
*******************************************************************************/
static TableData table(List<Map<String, Object>> rows, String first, String second, String third)
{
return (new TableData(null, List.of(new TableData.Column("html", first, "first", null, "left"),
new TableData.Column("html", second, "second", null, "left"), new TableData.Column("html", third, "third", null, "left")), rows)
.withRowsPerPage(10).withHidePaginationDropdown(true));
}
}
@@ -0,0 +1,86 @@
/*
* 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 java.util.List;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Standard widgets for the single CRM home. Today holds five statistics.
*******************************************************************************/
public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput>
{
public static final List<String> HOME = List.of("crm_today", "crm_setup", "crm_pipeline_summary", "crm_deal_forecast", "crm_rotting_deals",
"crm_recent_web_leads", "crm_goals", "crm_sales_leaderboard");
public static final List<String> TODAY = List.of("crm_open_pipeline", "crm_won_month", "crm_new_contacts", "crm_new_web_leads", "crm_overdue_activities");
/*******************************************************************************
** All metrics and tables use the same session-aware data renderer.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance)
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
List<String> labels = List.of("Today", "Setup", "Pipeline by stage", "Forecast", "Needs attention", "Recent web leads", "Goals", "Leaderboard");
for(int i = 0; i < HOME.size(); i++)
{
output.add(widget(HOME.get(i), labels.get(i), i == 0 ? WidgetType.PARENT_WIDGET : WidgetType.TABLE));
}
List<String> statistics = List.of("Open pipeline", "Won this month", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
for(int i = 0; i < TODAY.size(); i++)
{
output.add(widget(TODAY.get(i), statistics.get(i), WidgetType.STATISTICS));
}
return (output);
}
/*******************************************************************************
** A full-width, permission-protected standard widget.
*******************************************************************************/
private QWidgetMetaData widget(String name, String label, WidgetType type)
{
QWidgetMetaData widget = new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType()).withGridColumns(12)
.withIsCard(type != WidgetType.PARENT_WIDGET).withShowReloadButton(true)
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
.withCodeReference(new QCodeReference(CrmHomeRenderer.class));
if("crm_goals".equals(name))
{
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);
}
}
@@ -1,144 +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.
*/
/*******************************************************************************
** Renderer for the Pipeline Conversion widget.
**
** Queries DealStageHistory, counts transitions into each stage (entered)
** versus transitions out (progressed), and computes a conversion rate.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders pipeline conversion data as a table widget.
*******************************************************************************/
public class CrmPipelineConversionRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////
// query all deal stage history //
///////////////////////////////////////////////////////////
QueryOutput historyOutput = new QueryAction().execute(
new QueryInput(DealStageHistory.TABLE_NAME));
///////////////////////////////////////////////////////////
// count entered and progressed for each toStageId //
///////////////////////////////////////////////////////////
Map<Integer, Integer> enteredByStage = new HashMap<>();
Map<Integer, Integer> progressedByStage = new HashMap<>();
for(QRecord record : historyOutput.getRecords())
{
Integer toStageId = record.getValueInteger("toStageId");
Integer fromStageId = record.getValueInteger("fromStageId");
if(toStageId != null)
{
enteredByStage.merge(toStageId, 1, Integer::sum);
}
if(fromStageId != null)
{
progressedByStage.merge(fromStageId, 1, Integer::sum);
}
}
///////////////////////////////////////////////////////////
// build stage name lookup //
///////////////////////////////////////////////////////////
QueryOutput stageOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME));
Map<Integer, String> stageNames = new HashMap<>();
for(QRecord record : stageOutput.getRecords())
{
stageNames.put(record.getValueInteger("id"), record.getValueString("name"));
}
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Stage", "stageName", "2fr", null),
new TableData.Column("html", "Entered", "entered", "1fr", "right"),
new TableData.Column("html", "Progressed", "progressed", "1fr", "right"),
new TableData.Column("html", "Conversion %", "conversionRate", "1fr", "right")
);
//////////////////////////
// build table rows //
//////////////////////////
List<Map<String, Object>> rows = new ArrayList<>();
for(Map.Entry<Integer, Integer> entry : enteredByStage.entrySet())
{
Integer stageId = entry.getKey();
int entered = entry.getValue();
int progressed = progressedByStage.getOrDefault(stageId, 0);
BigDecimal conversionRate = entered > 0
? BigDecimal.valueOf(progressed).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(entered), 1, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
String stageName = stageNames.getOrDefault(stageId, "Stage " + stageId);
rows.add(MapBuilder.of(
"stageName", stageName,
"entered", entered,
"progressed", progressed,
"conversionRate", conversionRate + "%"
));
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(25)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,67 +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.
*/
/*******************************************************************************
** MetaData producer for the Pipeline Conversion widget.
**
** Shows conversion rates between pipeline stages based on DealStageHistory
** transition data.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_pipeline_conversion widget (TABLE type).
*******************************************************************************/
public class CrmPipelineConversionWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_pipeline_conversion";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Pipeline Conversion")
.withTooltip("Stage transition conversion rates")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("funnel_chart"))
.withCodeReference(new QCodeReference(CrmPipelineConversionRenderer.class));
}
}
@@ -1,179 +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.
*/
/*******************************************************************************
** Renderer for the CRM Pipeline Summary widget.
**
** Queries deals grouped by pipelineStageId for a given pipelineId and returns
** a table with columns: stageName, dealCount, totalAmount, weightedAmount.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders the pipeline summary as a table widget showing deal counts and
** amounts grouped by pipeline stage.
*******************************************************************************/
public class CrmPipelineSummaryRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////////
// get pipelineId from widget input query params //
///////////////////////////////////////////////////////////////
Integer pipelineId = null;
if(input.getQueryParams() != null && input.getQueryParams().containsKey("pipelineId"))
{
pipelineId = Integer.valueOf(input.getQueryParams().get("pipelineId"));
}
///////////////////////////////////////////////////////////////
// query all stages for the pipeline, ordered by sortOrder //
///////////////////////////////////////////////////////////////
QQueryFilter stageFilter = new QQueryFilter()
.withOrderBy(new QFilterOrderBy("sortOrder", true));
if(pipelineId != null)
{
stageFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, pipelineId));
}
QueryOutput stageOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME).withFilter(stageFilter));
//////////////////////////////////////////////////////////
// build a map of stageId -> stageName for display //
//////////////////////////////////////////////////////////
Map<Integer, String> stageNames = new LinkedHashMap<>();
for(QRecord stageRecord : stageOutput.getRecords())
{
stageNames.put(stageRecord.getValueInteger("id"), stageRecord.getValueString("name"));
}
///////////////////////////////////////////////////////////////
// query deals for the pipeline //
///////////////////////////////////////////////////////////////
QQueryFilter dealFilter = new QQueryFilter();
if(pipelineId != null)
{
dealFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, pipelineId));
}
QueryOutput dealOutput = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME).withFilter(dealFilter));
//////////////////////////////////////////////////////////
// aggregate deals by stage //
//////////////////////////////////////////////////////////
Map<Integer, Integer> countByStage = new LinkedHashMap<>();
Map<Integer, BigDecimal> totalAmountByStage = new LinkedHashMap<>();
Map<Integer, BigDecimal> weightedByStage = new LinkedHashMap<>();
for(Integer stageId : stageNames.keySet())
{
countByStage.put(stageId, 0);
totalAmountByStage.put(stageId, BigDecimal.ZERO);
weightedByStage.put(stageId, BigDecimal.ZERO);
}
for(QRecord dealRecord : dealOutput.getRecords())
{
Integer stageId = dealRecord.getValueInteger("pipelineStageId");
if(stageId == null || !stageNames.containsKey(stageId))
{
continue;
}
countByStage.merge(stageId, 1, Integer::sum);
BigDecimal amount = dealRecord.getValueBigDecimal("amount");
if(amount != null)
{
totalAmountByStage.merge(stageId, amount, BigDecimal::add);
}
BigDecimal weighted = dealRecord.getValueBigDecimal("weightedAmount");
if(weighted != null)
{
weightedByStage.merge(stageId, weighted, BigDecimal::add);
}
}
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Stage", "stageName", "2fr", null),
new TableData.Column("html", "Deal Count", "dealCount", "1fr", "right"),
new TableData.Column("html", "Total Amount", "totalAmount", "1fr", "right"),
new TableData.Column("html", "Weighted Amount", "weightedAmount", "1fr", "right")
);
//////////////////////////
// build table rows //
//////////////////////////
List<Map<String, Object>> rows = new ArrayList<>();
for(Integer stageId : stageNames.keySet())
{
rows.add(MapBuilder.of(
"stageName", stageNames.get(stageId),
"dealCount", countByStage.getOrDefault(stageId, 0),
"totalAmount", totalAmountByStage.getOrDefault(stageId, BigDecimal.ZERO).toPlainString(),
"weightedAmount", weightedByStage.getOrDefault(stageId, BigDecimal.ZERO).toPlainString()
));
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(50)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,67 +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.
*/
/*******************************************************************************
** MetaData producer for the CRM Pipeline Summary widget.
**
** Provides a TABLE view showing deal counts and amounts grouped by pipeline
** stage for a given pipeline.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_pipeline_summary widget (TABLE type).
*******************************************************************************/
public class CrmPipelineSummaryWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_pipeline_summary";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Pipeline Summary")
.withTooltip("Deal counts and amounts by pipeline stage")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("linear_scale"))
.withCodeReference(new QCodeReference(CrmPipelineSummaryRenderer.class));
}
}
@@ -1,171 +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.
*/
/*******************************************************************************
** Renderer for the CRM Quota Attainment widget.
**
** Queries the SalesGoal for the current user and current period, then queries
** won deals in that period to compute attainment percentage.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qbits.crm.scoring.model.SalesGoal;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Renders quota attainment as multi-statistics showing target, actual, and
** attainment percentage.
*******************************************************************************/
public class CrmQuotaAttainmentRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String currentUserId = QContext.getQSession() != null
? QContext.getQSession().getIdReference() : null;
LocalDate today = LocalDate.now();
///////////////////////////////////////////////
// query SalesGoal for current user/period //
///////////////////////////////////////////////
QueryOutput goalQuery = new QueryAction().execute(
new QueryInput(SalesGoal.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, currentUserId))
.withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today))
.withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today))));
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
if(goalQuery.getRecords().isEmpty())
{
///////////////////////////////////////////////
// no goal found -- show empty state //
///////////////////////////////////////////////
groups.add(new MultiStatisticsData.StatisticsGroupData()
.withHeader("No Goal Set")
.withSubheader("current period")
.withStatisticList(List.of(
new MultiStatisticsData.StatisticsGroupData.Statistic("Attainment", 0, "%")
)));
}
else
{
SalesGoal goal = new SalesGoal(goalQuery.getRecords().get(0));
///////////////////////////////////////////////
// find closed-won stage ids //
///////////////////////////////////////////////
QueryOutput stageQuery = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
List<Integer> closedWonStageIds = stageQuery.getRecords().stream()
.map(r -> r.getValueInteger("id"))
.toList();
///////////////////////////////////////////////
// query won deals in the period //
///////////////////////////////////////////////
BigDecimal totalWon = BigDecimal.ZERO;
if(!closedWonStageIds.isEmpty())
{
QQueryFilter dealFilter = new QQueryFilter()
.withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, currentUserId))
.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, closedWonStageIds))
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, goal.getPeriodStart()))
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, goal.getPeriodEnd()));
if(goal.getPipelineId() != null)
{
dealFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, goal.getPipelineId()));
}
QueryOutput dealQuery = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME).withFilter(dealFilter));
for(QRecord dealRecord : dealQuery.getRecords())
{
BigDecimal amount = dealRecord.getValueBigDecimal("amountInBaseCurrency");
if(amount != null)
{
totalWon = totalWon.add(amount);
}
}
}
///////////////////////////////////////////////
// compute attainment percentage //
///////////////////////////////////////////////
int attainmentPct = 0;
if(goal.getTargetAmount() != null && goal.getTargetAmount().compareTo(BigDecimal.ZERO) > 0)
{
attainmentPct = totalWon
.multiply(new BigDecimal(100))
.divide(goal.getTargetAmount(), 0, RoundingMode.HALF_UP)
.intValue();
}
groups.add(new MultiStatisticsData.StatisticsGroupData()
.withHeader("Quota Attainment")
.withSubheader(goal.getPeriodStart() + " to " + goal.getPeriodEnd())
.withStatisticList(List.of(
new MultiStatisticsData.StatisticsGroupData.Statistic("Target", goal.getTargetAmount().intValue(), null),
new MultiStatisticsData.StatisticsGroupData.Statistic("Actual", totalWon.intValue(), null),
new MultiStatisticsData.StatisticsGroupData.Statistic("Attainment", attainmentPct, "%")
)));
}
MultiStatisticsData data = new MultiStatisticsData("Quota Attainment", groups);
return (new RenderWidgetOutput(data));
}
}
@@ -1,68 +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.
*/
/*******************************************************************************
** MetaData producer for the CRM Quota Attainment widget.
**
** Provides a MULTI_STATISTICS view showing quota attainment percentage for
** the current user and current period.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
/*******************************************************************************
** Produces metadata for the crm_quota_attainment widget (MULTI_STATISTICS type).
*******************************************************************************/
public class CrmQuotaAttainmentWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_quota_attainment";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.MULTI_STATISTICS.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Quota Attainment")
.withTooltip("Your quota attainment for the current period")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new QIcon("flag"))
.withCodeReference(new QCodeReference(CrmQuotaAttainmentRenderer.class));
}
}
@@ -1,131 +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.
*/
/*******************************************************************************
** Renderer for the Recent Deals widget.
**
** Queries deals with actualCloseDate set, ordered by actualCloseDate DESC,
** limited to 20 rows. Returns table with name, amount, owner, closedDate,
** winLossReason.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders recent deals as a table widget.
*******************************************************************************/
public class CrmRecentDealsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////
// query recent deals with actualCloseDate set //
///////////////////////////////////////////////////////////
QueryOutput queryOutput = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK))
.withOrderBy(new QFilterOrderBy("actualCloseDate", false))
.withLimit(20)));
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Name", "name", "2fr", null),
new TableData.Column("html", "Amount", "amount", "1fr", "right"),
new TableData.Column("html", "Owner", "ownerUserId", "1fr", null),
new TableData.Column("html", "Closed Date", "closedDate", "1fr", "right"),
new TableData.Column("html", "Win/Loss Reason", "winLossReason", "1fr", null)
);
//////////////////////////
// build table rows //
//////////////////////////
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord record : queryOutput.getRecords())
{
String name = record.getValueString("name");
if(name == null)
{
name = "";
}
BigDecimal amount = record.getValueBigDecimal("amount");
String amountDisplay = amount != null ? "$" + amount.toPlainString() : "-";
String ownerUserId = record.getValueString("ownerUserId");
if(ownerUserId == null)
{
ownerUserId = "-";
}
LocalDate closedDate = record.getValueLocalDate("actualCloseDate");
String closedDateDisplay = closedDate != null ? closedDate.toString() : "-";
Integer winLossReasonId = record.getValueInteger("winLossReasonId");
String winLossDisplay = winLossReasonId != null ? String.valueOf(winLossReasonId) : "-";
rows.add(MapBuilder.of(
"name", name,
"amount", amountDisplay,
"ownerUserId", ownerUserId,
"closedDate", closedDateDisplay,
"winLossReason", winLossDisplay
));
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(20)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,66 +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.
*/
/*******************************************************************************
** MetaData producer for the Recent Deals widget.
**
** Shows the most recently closed deals with key details.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_recent_deals widget (TABLE type).
*******************************************************************************/
public class CrmRecentDealsWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_recent_deals";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Recent Deals")
.withTooltip("Most recently closed deals")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("receipt_long"))
.withCodeReference(new QCodeReference(CrmRecentDealsRenderer.class));
}
}
@@ -1,186 +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.
*/
/*******************************************************************************
** Renderer for the CRM Rotting Deals widget.
**
** Queries deals and their pipeline stages, finds deals where the number of
** days since stageEnteredDate exceeds the stage's rotDays threshold.
** Returns a table with columns: dealName, stageName, daysInStage, rotDays,
** ownerUserId, amount.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders the rotting deals table showing deals past their stage rot threshold.
*******************************************************************************/
public class CrmRottingDealsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
Instant now = Instant.now();
///////////////////////////////////////////////////////////////
// query all stages that have a rotDays value set //
///////////////////////////////////////////////////////////////
QueryOutput stageOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("rotDays", QCriteriaOperator.IS_NOT_BLANK))));
///////////////////////////////////////////////////////////////
// build a map of stageId -> PipelineStage for lookup //
///////////////////////////////////////////////////////////////
Map<Integer, PipelineStage> stageMap = new HashMap<>();
for(QRecord stageRecord : stageOutput.getRecords())
{
PipelineStage stage = new PipelineStage(stageRecord);
stageMap.put(stage.getId(), stage);
}
if(stageMap.isEmpty())
{
//////////////////////////////////////////////////////////
// no stages with rot days -- return empty table //
//////////////////////////////////////////////////////////
return (buildEmptyOutput());
}
///////////////////////////////////////////////////////////////
// query deals in those stages //
///////////////////////////////////////////////////////////////
QueryOutput dealOutput = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, new ArrayList<>(stageMap.keySet())))));
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Deal", "dealName", "2fr", null),
new TableData.Column("html", "Stage", "stageName", "1fr", null),
new TableData.Column("html", "Days in Stage", "daysInStage", "1fr", "right"),
new TableData.Column("html", "Rot Days", "rotDays", "1fr", "right"),
new TableData.Column("html", "Owner", "ownerUserId", "1fr", null),
new TableData.Column("html", "Amount", "amount", "1fr", "right")
);
//////////////////////////////////////////////////////////
// filter to only deals past the rot threshold //
//////////////////////////////////////////////////////////
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord dealRecord : dealOutput.getRecords())
{
Integer stageId = dealRecord.getValueInteger("pipelineStageId");
PipelineStage stage = stageMap.get(stageId);
if(stage == null || stage.getRotDays() == null)
{
continue;
}
Instant stageEnteredDate = dealRecord.getValueInstant("stageEnteredDate");
if(stageEnteredDate == null)
{
continue;
}
long daysInStage = ChronoUnit.DAYS.between(stageEnteredDate, now);
if(daysInStage > stage.getRotDays())
{
String dealName = dealRecord.getValueString("name");
String ownerUserId = dealRecord.getValueString("ownerUserId");
BigDecimal amount = dealRecord.getValueBigDecimal("amount");
rows.add(MapBuilder.of(
"dealName", dealName != null ? dealName : "",
"stageName", stage.getName(),
"daysInStage", (int) daysInStage,
"rotDays", stage.getRotDays(),
"ownerUserId", ownerUserId != null ? ownerUserId : "",
"amount", amount != null ? amount.toPlainString() : "0"
));
}
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(50)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
/***************************************************************************
** Build an empty table output when there are no rotting deals.
***************************************************************************/
private RenderWidgetOutput buildEmptyOutput()
{
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Deal", "dealName", "2fr", null),
new TableData.Column("html", "Stage", "stageName", "1fr", null),
new TableData.Column("html", "Days in Stage", "daysInStage", "1fr", "right"),
new TableData.Column("html", "Rot Days", "rotDays", "1fr", "right"),
new TableData.Column("html", "Owner", "ownerUserId", "1fr", null),
new TableData.Column("html", "Amount", "amount", "1fr", "right")
);
TableData tableData = new TableData(null, columns, new ArrayList<>())
.withRowsPerPage(50)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,67 +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.
*/
/*******************************************************************************
** MetaData producer for the CRM Rotting Deals widget.
**
** Provides a TABLE view showing deals that have exceeded their current
** stage's rotDays threshold, indicating they need attention.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_rotting_deals widget (TABLE type).
*******************************************************************************/
public class CrmRottingDealsWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_rotting_deals";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(6)
.withIsCard(true)
.withLabel("Rotting Deals")
.withTooltip("Deals that have exceeded their stage's rot days threshold")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("warning"))
.withCodeReference(new QCodeReference(CrmRottingDealsRenderer.class));
}
}
@@ -1,147 +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.
*/
/*******************************************************************************
** Renderer for the Sales Cycle Length widget.
**
** Queries closedWon deals and computes the average number of days from
** createDate to actualCloseDate.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders sales cycle length as a single-stat table widget.
*******************************************************************************/
public class CrmSalesCycleLengthRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////
// find closedWon stage IDs //
///////////////////////////////////////////////////////////
Set<Integer> closedWonStageIds = new HashSet<>();
QueryOutput stageOutput = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
for(QRecord record : stageOutput.getRecords())
{
closedWonStageIds.add(record.getValueInteger("id"));
}
///////////////////////////////////////////////////////////
// query deals with actualCloseDate set //
///////////////////////////////////////////////////////////
QueryOutput dealOutput = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK))));
///////////////////////////////////////////////////////////
// compute average days for closedWon deals //
///////////////////////////////////////////////////////////
long totalDays = 0;
int count = 0;
for(QRecord record : dealOutput.getRecords())
{
Integer stageId = record.getValueInteger("pipelineStageId");
if(closedWonStageIds.contains(stageId))
{
Instant createDate = record.getValueInstant("createDate");
LocalDate actualCloseDate = record.getValueLocalDate("actualCloseDate");
if(createDate != null && actualCloseDate != null)
{
LocalDate createLocalDate = createDate.atZone(ZoneId.systemDefault()).toLocalDate();
long days = ChronoUnit.DAYS.between(createLocalDate, actualCloseDate);
totalDays += days;
count++;
}
}
}
BigDecimal avgCycleLength = count > 0
? BigDecimal.valueOf(totalDays).divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
///////////////////////////
// build table output //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Metric", "metric", "1fr", null),
new TableData.Column("html", "Value", "value", "1fr", "right")
);
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(MapBuilder.of(
"metric", "Avg Sales Cycle (days)",
"value", avgCycleLength.toPlainString()
));
rows.add(MapBuilder.of(
"metric", "Closed Won Deals",
"value", String.valueOf(count)
));
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(10)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
@@ -1,66 +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.
*/
/*******************************************************************************
** MetaData producer for the Sales Cycle Length widget.
**
** Shows the average number of days from deal creation to close for won deals.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Produces metadata for the crm_sales_cycle_length widget (TABLE type).
*******************************************************************************/
public class CrmSalesCycleLengthWidgetProducer extends MetaDataProducer<QWidgetMetaData>
{
public static final String NAME = "crm_sales_cycle_length";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.TABLE.getType())
.withGridColumns(3)
.withIsCard(true)
.withLabel("Sales Cycle Length")
.withTooltip("Average days from creation to close for won deals")
.withShowReloadButton(true)
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("schedule"))
.withCodeReference(new QCodeReference(CrmSalesCycleLengthRenderer.class));
}
}
@@ -1,137 +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.
*/
/*******************************************************************************
** Renderer for the CRM Sales Leaderboard widget.
**
** Queries deals in closed-won stages, groups by ownerUserId, sums
** amountInBaseCurrency, and returns a ranked table.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** Renders the sales leaderboard as a table widget, ranking reps by closed-won
** deal revenue.
*******************************************************************************/
public class CrmSalesLeaderboardRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////
// find closed-won stage ids //
///////////////////////////////////////////////////////////
QueryOutput stageQuery = new QueryAction().execute(
new QueryInput(PipelineStage.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
List<Integer> closedWonStageIds = stageQuery.getRecords().stream()
.map(r -> r.getValueInteger("id"))
.toList();
///////////////////////////////////////////////////////////
// query deals in closed-won stages //
///////////////////////////////////////////////////////////
Map<String, BigDecimal> revenueByOwner = new LinkedHashMap<>();
if(!closedWonStageIds.isEmpty())
{
QueryOutput dealQuery = new QueryAction().execute(
new QueryInput(Deal.TABLE_NAME)
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, closedWonStageIds))));
for(QRecord dealRecord : dealQuery.getRecords())
{
String ownerUserId = dealRecord.getValueString("ownerUserId");
BigDecimal amount = dealRecord.getValueBigDecimal("amountInBaseCurrency");
if(ownerUserId != null && amount != null)
{
revenueByOwner.merge(ownerUserId, amount, BigDecimal::add);
}
}
}
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> columns = List.of(
new TableData.Column("html", "Rank", "rank", "0.5fr", null),
new TableData.Column("html", "Owner", "owner", "2fr", null),
new TableData.Column("html", "Revenue", "revenue", "1fr", "right")
);
//////////////////////////
// build ranked rows //
//////////////////////////
List<Map.Entry<String, BigDecimal>> sorted = new ArrayList<>(revenueByOwner.entrySet());
sorted.sort(Comparator.comparing(Map.Entry<String, BigDecimal>::getValue).reversed());
List<Map<String, Object>> rows = new ArrayList<>();
int rank = 1;
for(Map.Entry<String, BigDecimal> entry : sorted)
{
rows.add(MapBuilder.of(
"rank", String.valueOf(rank),
"owner", entry.getKey(),
"revenue", entry.getValue().toPlainString()
));
rank++;
}
TableData tableData = new TableData(null, columns, rows)
.withRowsPerPage(25)
.withHidePaginationDropdown(true);
return (new RenderWidgetOutput(tableData));
}
}
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more