Compare commits
41
Commits
v0.2.0-RC.1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7a8b5089
|
||
|
|
51ca0bb920 | ||
|
|
d0f558dc60
|
||
|
|
945f01371a
|
||
|
|
021e86c202
|
||
|
|
a48933bedc
|
||
|
|
c9acd0921c
|
||
|
|
60f8ced9bb
|
||
|
|
746d2edd17
|
||
|
|
ee6ee59793
|
||
|
|
409e900f10
|
||
|
|
9094e9a760
|
||
|
|
72bc4267e6
|
||
|
|
d6f21f6594 | ||
|
|
00f798668a | ||
|
|
a7b42339b9 | ||
|
|
e2990bcd60 | ||
|
|
e422092fd4 | ||
|
|
70f841c256 | ||
|
|
730bb68428 | ||
|
|
62c393374d | ||
|
|
c380dce8b6
|
||
|
|
a8e9a34b51 | ||
|
|
26b8847b46
|
||
|
|
820474805d | ||
|
|
5157f81208
|
||
|
|
c6031c26ed | ||
|
|
08e880af2e
|
||
|
|
e3f20d357e | ||
|
|
4710ed38b3
|
||
|
|
0ebbeefa08 | ||
|
|
c5383718a3
|
||
|
|
a025eeee2f | ||
|
|
80bf85a158
|
||
|
|
24707575fc | ||
|
|
d6900e3842
|
||
|
|
dc4ad764f3
|
||
|
|
4b4b33127e
|
||
|
|
c8d765a135 | ||
|
|
2babcaf3b4
|
||
|
|
31964ff0e0
|
No files matched your search
+11
-57
@@ -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+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -2,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).
|
||||
@@ -62,9 +62,9 @@ qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
- `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields.
|
||||
- `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads.
|
||||
- `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables.
|
||||
- `CrmHealthChecks.indicators(monitoringSession)`: register the returned indicators with the host health middleware. Include optional `qqq-middleware-health`; the supplied session determines tenant scope and is restored on worker threads. `run()` uses the caller's context. No check adds permissions or tenant keys.
|
||||
- Health: the producer registers `crm.email`, `crm.sequence_lag` and `crm.lead_score` with the core health registry (`HealthChecksMetaData`). Hosts that enable core health monitoring (`HealthMetaDataProvider`) get them scheduled by the core `HealthRunner`, which runs each check as the core system user. `CrmHealthChecks.run()` evaluates them once in the caller's context. No check adds permissions or tenant keys or returns personal values.
|
||||
|
||||
The shipped help bundle and health adapter are temporary core integration adapters; their removal target is RC.9. See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
|
||||
Help and health both use the core registries (`HelpBundles`, `HealthChecksMetaData`). See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
|
||||
|
||||
| Scope | Permissions |
|
||||
|---|---|
|
||||
@@ -77,6 +77,16 @@ The shipped help bundle and health adapter are temporary core integration adapte
|
||||
|
||||
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 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.
|
||||
@@ -164,6 +174,24 @@ Later phases complete the following behavior:
|
||||
|
||||
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase.
|
||||
|
||||
## In-app help
|
||||
|
||||
CRM registers [help/qbit-crm.yaml](src/main/resources/help/qbit-crm.yaml) from
|
||||
`CrmQBitProducer`. The bundle uses the `QBIT` layer and the current artifact version.
|
||||
Registration only adds metadata and works without a `helpContent` table; QQQ's
|
||||
startup sync skips hosts without that table. Hosts with help storage load help
|
||||
and call `HelpBundles.syncOnStartup(qInstance)` after defining their metadata.
|
||||
|
||||
To customize wording or describe your deployment, register a separate `HOST`
|
||||
bundle with the same entry keys (and roles). HOST text takes precedence over QBIT
|
||||
text; the sync preserves user edits for review. Keep live `{{...}}` references
|
||||
when referring to labels, defaults or enum values.
|
||||
|
||||
`CrmPublicationHelpTest` runs `HelpBundleAssertions` against the produced and
|
||||
validated CRM instance, including generated record actions, and also requires
|
||||
widget and possible-value help. `CrmTablesGuardrailTest` checks every table's
|
||||
row-creation guidance. Run both through `./mvnw -q verify`.
|
||||
|
||||
## Source layout
|
||||
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
|
||||
+24
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -179,8 +179,8 @@ CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
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,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
email VARCHAR(320),
|
||||
secondary_email VARCHAR(320),
|
||||
phone VARCHAR(50),
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.2.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -33,7 +34,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.2</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -78,16 +79,11 @@
|
||||
<artifactId>qqq-middleware-api</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-health</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>0.1.0-RC.3</version>
|
||||
<version>0.1.0-RC.11</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -113,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>
|
||||
@@ -128,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>
|
||||
@@ -197,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,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,19 @@
|
||||
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;
|
||||
@@ -41,7 +46,7 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
import com.kingsrook.qbits.crm.health.CrmHealthChecks;
|
||||
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
|
||||
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
@@ -64,6 +69,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
@@ -84,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.
|
||||
*******************************************************************************/
|
||||
@@ -118,18 +152,20 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig);
|
||||
CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig);
|
||||
CrmHealthChecks.register(qInstance, qBitConfig);
|
||||
for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
|
||||
{
|
||||
widget.setName(snakeCase(widget.getName()));
|
||||
QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker();
|
||||
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker));
|
||||
widget.setGridColumns(12);
|
||||
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)
|
||||
{
|
||||
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()));
|
||||
@@ -141,8 +177,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
}
|
||||
|
||||
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_form_submission").setLabel("Web Leads");
|
||||
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_email_list").setLabel("Lists");
|
||||
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"));
|
||||
@@ -165,6 +199,11 @@ 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));
|
||||
@@ -228,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 //
|
||||
///////////////////////////////////////////////////////////////
|
||||
@@ -369,7 +417,8 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
|
||||
registerHooks(qInstance);
|
||||
CrmHelpBundleStandIn.apply(output);
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -295,7 +294,7 @@ public class CrmIntakeApi
|
||||
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
|
||||
@@ -396,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,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);
|
||||
}
|
||||
}
|
||||
+117
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -101,26 +101,23 @@ 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")));
|
||||
|
||||
|
||||
|
||||
@@ -181,10 +178,10 @@ 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 = 320, 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)
|
||||
|
||||
+8
@@ -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 //
|
||||
///////////////////////////////////////////
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.crm.health;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
@@ -32,9 +33,15 @@ import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -42,22 +49,25 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)
|
||||
** CRM checks for hosts to register with their health middleware. Run in the
|
||||
** caller's tenant context; no check elevates access or returns personal values.
|
||||
** CRM checks registered with the core health registry (HealthChecksMetaData).
|
||||
** The core HealthRunner schedules them and runs each one in its own automated
|
||||
** system-user context; no check returns personal values.
|
||||
*******************************************************************************/
|
||||
public class CrmHealthChecks
|
||||
{
|
||||
public static final String EMAIL = "crm.email";
|
||||
public static final String SEQUENCE_LAG = "crm.sequence_lag";
|
||||
public static final String LEAD_SCORE = "crm.lead_score";
|
||||
|
||||
private static final String COMPONENT = "crm";
|
||||
|
||||
private final CrmEmailTransport transport;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same readiness decision as actual email delivery.
|
||||
*******************************************************************************/
|
||||
@@ -67,6 +77,7 @@ public class CrmHealthChecks
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow a host transport adapter.
|
||||
*******************************************************************************/
|
||||
@@ -76,117 +87,141 @@ public class CrmHealthChecks
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run all checks without registering tables or starting background workers.
|
||||
** Register the CRM checks with the instance's core health registry.
|
||||
** Production may run repeatedly; an already registered name is kept.
|
||||
*******************************************************************************/
|
||||
public Map<String, HealthCheckResult> run() throws QException
|
||||
public static void register(QInstance instance, CrmQBitConfig config)
|
||||
{
|
||||
return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore()));
|
||||
new CrmHealthChecks(config).registerWith(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expose individual checks through the RC.6 health SPI.
|
||||
** Register these checks (with this transport) with the core health registry.
|
||||
*******************************************************************************/
|
||||
public List<HealthIndicator> indicators()
|
||||
public void registerWith(QInstance instance)
|
||||
{
|
||||
return (indicators(QContext.getQSession() == null ? new QSession() : QContext.getQSession()));
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
add(registry, EMAIL, "CRM email transport", HealthCategory.EMAIL, 300);
|
||||
add(registry, SEQUENCE_LAG, "CRM sequence lag", HealthCategory.JOBS, 300);
|
||||
add(registry, LEAD_SCORE, "CRM lead scoring", HealthCategory.JOBS, 900);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply the host's monitoring session explicitly for middleware worker threads.
|
||||
** Its permissions and tenant keys are preserved; this adapter never grants keys.
|
||||
*******************************************************************************/
|
||||
public List<HealthIndicator> indicators(QSession session)
|
||||
{
|
||||
return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One independently evaluated middleware indicator.
|
||||
** Run all checks once in the caller's context, without registering anything.
|
||||
*******************************************************************************/
|
||||
private class Indicator implements HealthIndicator
|
||||
public Map<String, HealthCheckOutcome> run() throws QException
|
||||
{
|
||||
private final String name;
|
||||
private final QSession session;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select a check.
|
||||
*******************************************************************************/
|
||||
private Indicator(String name, QSession session)
|
||||
Map<String, HealthCheckOutcome> outcomes = new LinkedHashMap<>();
|
||||
for(String name : List.of(EMAIL, SEQUENCE_LAG, LEAD_SCORE))
|
||||
{
|
||||
this.name = name;
|
||||
this.session = session;
|
||||
outcomes.put(name, check(name).run(new HealthCheckInput()));
|
||||
}
|
||||
return (outcomes);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getName()
|
||||
|
||||
/*******************************************************************************
|
||||
** The core SPI body for one named check. Failures are reported as UNKNOWN
|
||||
** with a fixed message, so no exception text (or value) leaves the check.
|
||||
*******************************************************************************/
|
||||
public HealthCheck check(String name)
|
||||
{
|
||||
return (new HealthCheck()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public HealthCheckResult check(QInstance instance)
|
||||
{
|
||||
CapturedContext original = QContext.capture();
|
||||
try
|
||||
/*******************************************************************
|
||||
** Evaluate the named check in the current QContext.
|
||||
*******************************************************************/
|
||||
@Override
|
||||
public HealthCheckOutcome run(HealthCheckInput input)
|
||||
{
|
||||
QContext.init(instance, session);
|
||||
return (switch(name)
|
||||
try
|
||||
{
|
||||
case "crm.email" -> email();
|
||||
case "crm.sequenceLag" -> sequenceLag();
|
||||
default -> leadScore();
|
||||
});
|
||||
return (switch(name)
|
||||
{
|
||||
case EMAIL -> email();
|
||||
case SEQUENCE_LAG -> sequenceLag();
|
||||
case LEAD_SCORE -> leadScore();
|
||||
default -> throw (new IllegalArgumentException("Unknown CRM health check"));
|
||||
});
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("CRM check could not be completed"));
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(original);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add one check definition unless the name is already registered.
|
||||
*******************************************************************************/
|
||||
private void add(HealthChecksMetaData registry, String name, String label, HealthCategory category, int intervalSeconds)
|
||||
{
|
||||
if(registry.getChecks().stream().anyMatch(existing -> name.equals(existing.getName())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
registry.withCheck(new HealthCheckMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withComponent(COMPONENT)
|
||||
.withCategory(category)
|
||||
.withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withSeverity(AlertSeverity.WARNING)
|
||||
.withIntervalSeconds(intervalSeconds)
|
||||
.withTimeoutMs(5000)
|
||||
.withCheckInstance(check(name)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unconfigured transport is degraded, not a claim that email can be sent.
|
||||
*******************************************************************************/
|
||||
private HealthCheckResult email() throws QException
|
||||
private HealthCheckOutcome email() throws QException
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP));
|
||||
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED
|
||||
? HealthCheckOutcome.degraded("Email transport not configured")
|
||||
: HealthCheckOutcome.up("Email transport ready"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query just the oldest active due enrollment.
|
||||
*******************************************************************************/
|
||||
private HealthCheckResult sequenceLag() throws QException
|
||||
private HealthCheckOutcome sequenceLag() throws QException
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId()))
|
||||
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN, Instant.now().minus(30, ChronoUnit.MINUTES)))
|
||||
.withOrderBy(new QFilterOrderBy("nextStepDate")).withLimit(1))).getRecords();
|
||||
return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED));
|
||||
return (rows.isEmpty()
|
||||
? HealthCheckOutcome.up("No sequence step is more than 30 minutes late")
|
||||
: HealthCheckOutcome.degraded("A sequence step is more than 30 minutes late"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No successful score pass, or one more than three hours old, needs attention.
|
||||
*******************************************************************************/
|
||||
private HealthCheckResult leadScore() throws QException
|
||||
private HealthCheckOutcome leadScore() throws QException
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME).withFilter(new QQueryFilter(
|
||||
new QFilterCriteria("name", QCriteriaOperator.EQUALS, CalculateLeadScoresProcess.NAME)).withLimit(1))).getRecords();
|
||||
Instant last = rows.isEmpty() ? null : rows.getFirst().getValueInstant("lastSuccess");
|
||||
return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
|
||||
? HealthStatus.DEGRADED : HealthStatus.UP));
|
||||
return (last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
|
||||
? HealthCheckOutcome.degraded("No lead score pass succeeded in the last 3 hours")
|
||||
: HealthCheckOutcome.up("Lead scores are current"));
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.help;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)
|
||||
** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks
|
||||
** process/PVS help slots: action content is applied to frontend steps instead;
|
||||
** enum entries remain in the bundle for the central loader.
|
||||
*******************************************************************************/
|
||||
public final class CrmHelpBundleStandIn
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmHelpBundleStandIn()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the resource eagerly so malformed shipped help fails production.
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml"))
|
||||
{
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1
|
||||
|| !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("Invalid CRM bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120
|
||||
|| entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("Invalid or duplicate help entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("Could not load help/qbit-crm.yaml", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply help to the objects produced by this QBit only.
|
||||
*******************************************************************************/
|
||||
public static void apply(MetaDataProducerMultiOutput output) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : output.getEach(QTableMetaData.class))
|
||||
{
|
||||
table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help"));
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
String key = "table:" + table.getName() + ";field:" + field.getName();
|
||||
if(entries.containsKey(key))
|
||||
{
|
||||
field.withHelpContent(content(entries, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QProcessMetaData process : output.getEach(QProcessMetaData.class))
|
||||
{
|
||||
QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help");
|
||||
for(var step : process.getStepList())
|
||||
{
|
||||
if(step instanceof QFrontendStepMetaData frontend)
|
||||
{
|
||||
frontend.withHelpContent(help.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QAppMetaData app : output.getEach(QAppMetaData.class))
|
||||
{
|
||||
app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 has no summary property, so retain the summary in the bundle only.
|
||||
*******************************************************************************/
|
||||
private static QHelpContent content(Map<String, JsonNode> entries, String key) throws QException
|
||||
{
|
||||
if(!entries.containsKey(key))
|
||||
{
|
||||
throw (new QException("Missing CRM help entry: " + key));
|
||||
}
|
||||
return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText()));
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -105,7 +105,11 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Instant startedAt = Instant.now();
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// modifyDate is kept at millisecond precision; a microsecond watermark //
|
||||
// would skip a change made in the same millisecond the pass started. //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
Instant startedAt = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
List<QRecord> states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords();
|
||||
QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst();
|
||||
|
||||
@@ -22,19 +22,26 @@
|
||||
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 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.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.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -70,9 +77,41 @@ public class CrmContactTimelineRenderer extends AbstractWidgetRenderer
|
||||
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")), record.getValue("createDate"), kind, detail));
|
||||
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), formatDate(record.getValueInstant("createDate")), kind, detail));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ 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;
|
||||
@@ -33,6 +35,7 @@ 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;
|
||||
@@ -89,6 +92,8 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
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":
|
||||
@@ -118,11 +123,20 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve decimals and large counts in the standard statistics renderer.
|
||||
** Format statistics with the configured base currency in the value.
|
||||
*******************************************************************************/
|
||||
private StatisticsData statistic(BigDecimal value, boolean currency)
|
||||
private StatisticsData statistic(BigDecimal value, boolean currency) throws QException
|
||||
{
|
||||
return (new StatisticsData().withCount(value).withCountContext(currency ? "base currency" : null));
|
||||
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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +211,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stage and deal count share one column, keeping both monetary columns.
|
||||
** Separate the stage label, integer count and weighted base-currency amount.
|
||||
*******************************************************************************/
|
||||
private TableData pipeline(RenderWidgetInput input) throws QException
|
||||
{
|
||||
@@ -220,10 +234,10 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
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].toPlainString() + " deals)", values[1], values[2]));
|
||||
rows.add(row(href, stage.getValueString("name"), values[0].toBigIntegerExact(), formatAmount(values[2])));
|
||||
}
|
||||
}
|
||||
TableData data = table(rows, "Stage / deals", "Amount (base)", "Weighted amount (base)");
|
||||
TableData data = table(rows, "Stage", "Deals", "Weighted amount");
|
||||
if(pipelines.size() > 1)
|
||||
{
|
||||
data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline"))
|
||||
@@ -235,6 +249,22 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -246,7 +276,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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], totals[2]));
|
||||
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)"));
|
||||
}
|
||||
@@ -292,7 +322,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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(name.isBlank())
|
||||
if(Objects.toString(lead.getValue("firstName"), "").isBlank() || Objects.toString(lead.getValue("lastName"), "").isBlank())
|
||||
{
|
||||
name = Objects.toString(lead.getValue("email"), "Web lead");
|
||||
}
|
||||
@@ -339,7 +369,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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 + ")", target, actual));
|
||||
goal.getValueString("userId") + " (" + attainment + ")", formatAmount(target), formatAmount(actual)));
|
||||
}
|
||||
TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)");
|
||||
return (data);
|
||||
@@ -362,7 +392,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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)), number(result.getAggregateValue(AMOUNT))));
|
||||
number(result.getAggregateValue(COUNT)).toBigIntegerExact(), formatAmount(number(result.getAggregateValue(AMOUNT)))));
|
||||
}
|
||||
return (table(rows, "Owner", "Won deals", "Won amount (base)"));
|
||||
}
|
||||
@@ -457,6 +487,18 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -37,7 +37,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
*******************************************************************************/
|
||||
public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput>
|
||||
{
|
||||
public static final List<String> HOME = List.of("crm_today", "crm_pipeline_summary", "crm_deal_forecast", "crm_rotting_deals",
|
||||
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");
|
||||
|
||||
@@ -49,12 +49,12 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance)
|
||||
{
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
List<String> labels = List.of("Today", "Pipeline by stage", "Forecast", "Needs attention", "Recent web leads", "Goals", "Leaderboard");
|
||||
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 (base currency)", "Won this month (base currency)", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
|
||||
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));
|
||||
@@ -76,6 +76,11 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
|
||||
{
|
||||
widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class));
|
||||
}
|
||||
if("crm_setup".equals(name))
|
||||
{
|
||||
widget.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.MANAGE_BASE))
|
||||
.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmSetupVisibility.class));
|
||||
}
|
||||
return (widget);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the CRM home: the configuration the CRM's processes depend
|
||||
** on, whether it is in place, and the next step. Reads through core actions, so
|
||||
** the signed-in user's permissions and tenant locks apply.
|
||||
*******************************************************************************/
|
||||
public final class CrmSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: in place. TODO: something the CRM needs is missing. OPTIONAL: a feature
|
||||
** that stays off until it is set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK("Set up"),
|
||||
TODO("Needs setup"),
|
||||
OPTIONAL("Optional");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
State(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when any business setup step still needs attention.
|
||||
*******************************************************************************/
|
||||
public static boolean isIncomplete() throws QException
|
||||
{
|
||||
return (load().stream().anyMatch(item -> item.state() == State.TODO));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items for the current user.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load() throws QException
|
||||
{
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// pipeline and stages: Convert lead needs a default pipeline //
|
||||
// with at least one stage //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
List<QRecord> defaults = CrmHomeRenderer.query("crm_pipeline", CrmHomeRenderer.equalsFilter("isDefault", true), "id", true, 1);
|
||||
if(defaults.isEmpty())
|
||||
{
|
||||
items.add(new Item("Default pipeline", "crm_pipeline", State.TODO, "None: Convert lead fails without one.",
|
||||
"Add a pipeline and tick Default (or include the crm-defaults changelog)."));
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.TODO, "A deal needs a pipeline with stages.", "Add its stages once the pipeline exists."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Default pipeline", "crm_pipeline", State.OK, defaults.getFirst().getValueString("name") + " is the default.", null));
|
||||
List<QRecord> stages = CrmHomeRenderer.query("crm_pipeline_stage", CrmHomeRenderer.equalsFilter("pipelineId", defaults.getFirst().getValueInteger("id")), "sortOrder", true, 200);
|
||||
boolean won = stages.stream().anyMatch(stage -> Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")));
|
||||
boolean lost = stages.stream().anyMatch(stage -> Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")));
|
||||
if(stages.isEmpty())
|
||||
{
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.TODO, "The default pipeline has no stages: Convert lead fails.",
|
||||
"Add its stages, in order, with a win probability each."));
|
||||
}
|
||||
else if(!won || !lost)
|
||||
{
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.TODO, stages.size() + " stage" + (stages.size() == 1 ? "" : "s") + ", but no " + (won ? "Closed Lost" : "Closed Won") + " stage.",
|
||||
"Tick Closed Won and Closed Lost on the stages that end a deal: the home, goals and reports count deals from them."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.OK, stages.size() + " stages in the default pipeline.", null));
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// lifecycle stages: the CRM looks up "Lead" and "Opportunity" by name //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(String name : List.of("Lead", "Opportunity"))
|
||||
{
|
||||
if(CrmHomeRenderer.query("crm_lifecycle_stage", CrmHomeRenderer.equalsFilter("name", name), "id", true, 1).isEmpty())
|
||||
{
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
if(missing.isEmpty())
|
||||
{
|
||||
items.add(new Item("Lifecycle stages", "crm_lifecycle_stage", State.OK, "Lead and Opportunity exist.", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
String effect = missing.size() == 2 ? "new web leads get no stage and Convert lead does not move the contact"
|
||||
: missing.contains("Lead") ? "new web leads get no stage" : "Convert lead does not move the contact";
|
||||
items.add(new Item("Lifecycle stages", "crm_lifecycle_stage", State.TODO, "Missing " + String.join(" and ", missing) + ": " + effect + ".",
|
||||
"Add " + (missing.size() == 2 ? "stages" : "a stage") + " named exactly " + String.join(" and ", missing.stream().map(name -> "\"" + name + "\"").toList()) + "."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// currency: amounts need a base currency for the prefix //
|
||||
///////////////////////////////////////////////////////////
|
||||
List<QRecord> base = CrmHomeRenderer.query("crm_currency", CrmHomeRenderer.equalsFilter("isBaseCurrency", true), "id", true, 1);
|
||||
items.add(base.isEmpty()
|
||||
? new Item("Base currency", "crm_currency", State.TODO, "None: amounts have no currency prefix and conversions do nothing.", "Add your currency with Base currency ticked and rate 1.")
|
||||
: new Item("Base currency", "crm_currency", State.OK, base.getFirst().getValueString("currencyCode") + " is the base currency.", null));
|
||||
|
||||
return (items);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Show the Setup card only to CRM settings holders (crm.manage) and only while
|
||||
** a business setup step is still missing. Evaluated at request time, so a
|
||||
** completed setup drops the card from per-session metadata and from render.
|
||||
*******************************************************************************/
|
||||
public class CrmSetupVisibility implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Require crm.hasAccess and crm.manage.hasAccess, then an incomplete setup.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS) || !QContext.getQSession().hasPermission(CrmPermissions.MANAGE))
|
||||
{
|
||||
throw (new QPermissionDeniedException("CRM settings access is required."));
|
||||
}
|
||||
try
|
||||
{
|
||||
if(CrmSetupStatus.isIncomplete())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Setup status is not available.", e));
|
||||
}
|
||||
throw (new QPermissionDeniedException("CRM setup is complete."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
-- liquibase formatted sql
|
||||
-- Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0.
|
||||
-- https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
-- qbit-crm starter defaults: a CRM that works out of the box.
|
||||
-- Include it from the host's master changelog AFTER crm.changelog.sql (or
|
||||
-- docs/ddl/postgres.sql) and, on a tenant-enabled install, AFTER
|
||||
-- crm-tenancy.changelog.sql too (so the tenant_key check below sees the columns):
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
|
||||
--
|
||||
-- Without these rows, converting a lead fails ("no default pipeline"), deals
|
||||
-- and web leads silently lose their lifecycle stage, and amounts have no
|
||||
-- currency prefix.
|
||||
--
|
||||
-- Rules for this file:
|
||||
-- * insert-only and never disruptive: a list is seeded only while its table is
|
||||
-- empty (a host that configured its own keeps exactly that), the pipeline
|
||||
-- only when no pipeline exists, its stages only when that is the one
|
||||
-- pipeline and no stage or deal exists. The lifecycle stages (which change
|
||||
-- what intake and Convert lead assign) and the base currency are seeded
|
||||
-- only on a fresh install: no contact (respectively no deal) exists yet.
|
||||
-- Nothing is ever updated, so an administrator's edits survive;
|
||||
-- * every rollback is empty: a rollback must never delete rows the host or a
|
||||
-- tenant owns (Liquibase runs rollbacks even for MARK_RAN changesets);
|
||||
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
|
||||
-- * not for tenant-enabled installs: once the crm-tenancy changelog has added
|
||||
-- tenant_key to a table, that table's changeset is skipped (MARK_RAN).
|
||||
-- Every row of a tenant-locked table needs a tenant key and only the host
|
||||
-- knows its tenants, so such a host seeds these same rows once per tenant,
|
||||
-- with tenant_key set;
|
||||
-- * every changeset checks that its tables exist (SELECT ... WHERE 1 = 0 errors
|
||||
-- when one is missing): a host that has not created them yet simply skips it
|
||||
-- (MARK_RAN).
|
||||
-- CrmDefaultsChangelogTest runs this file twice against a fresh schema.
|
||||
|
||||
-- changeset qbit-crm:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_activity_type WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_activity_type' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: The six activity kinds the CRM logs. Same codes and names CrmStarterData creates on first use, so that stays a no-op.
|
||||
INSERT INTO crm_activity_type (code, name, is_system, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.code, v.n, TRUE, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'NOTE' AS code, 'Note' AS n, 0 AS so
|
||||
UNION ALL SELECT 'EMAIL', 'Email', 1
|
||||
UNION ALL SELECT 'CALL', 'Call', 2
|
||||
UNION ALL SELECT 'MEETING', 'Meeting', 3
|
||||
UNION ALL SELECT 'FORM_SUBMISSION', 'Form submission', 4
|
||||
UNION ALL SELECT 'SEQUENCE_STEP', 'Sequence step', 5) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_activity_type x WHERE x.code = v.code OR x.name = v.n);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-2
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_activity_outcome WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_activity_type WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_activity_outcome' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Call and meeting outcomes, offered when an activity of that type is logged. Needs the activity types above.
|
||||
INSERT INTO crm_activity_outcome (activity_type_id, name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT t.id, v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM crm_activity_type t
|
||||
JOIN (SELECT 'CALL' AS code, 'Connected' AS n, 1 AS so
|
||||
UNION ALL SELECT 'CALL', 'Left voicemail', 2
|
||||
UNION ALL SELECT 'CALL', 'No answer', 3
|
||||
UNION ALL SELECT 'MEETING', 'Meeting held', 1
|
||||
UNION ALL SELECT 'MEETING', 'Meeting no-show', 2) v ON v.code = t.code
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_activity_outcome);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-3
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_pipeline WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_pipeline' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: The default pipeline, only when the host has no pipeline yet. Convert lead refuses to run without a default one.
|
||||
INSERT INTO crm_pipeline (name, description, is_default, is_active, create_date, modify_date)
|
||||
SELECT 'Sales Pipeline', 'Default sales process for new deals and converted leads.', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_pipeline);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-4
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_pipeline WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_pipeline_stage WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_pipeline' AND LOWER(column_name) = 'tenant_key'
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_deal WHERE 1 = 0
|
||||
-- comment: Stages of Sales Pipeline with their win probability, only when it is the one pipeline, no stage and no deal exists (that is, defaults-3 created it). Closed Won and Closed Lost end a deal.
|
||||
INSERT INTO crm_pipeline_stage (pipeline_id, name, sort_order, probability_pct, is_closed_won, is_closed_lost, create_date, modify_date)
|
||||
SELECT p.id, v.n, v.so, v.pct, v.won, v.lost, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM crm_pipeline p
|
||||
JOIN (SELECT 'Qualification' AS n, 1 AS so, 10 AS pct, FALSE AS won, FALSE AS lost
|
||||
UNION ALL SELECT 'Discovery', 2, 25, FALSE, FALSE
|
||||
UNION ALL SELECT 'Proposal', 3, 50, FALSE, FALSE
|
||||
UNION ALL SELECT 'Negotiation', 4, 75, FALSE, FALSE
|
||||
UNION ALL SELECT 'Closed Won', 5, 100, TRUE, FALSE
|
||||
UNION ALL SELECT 'Closed Lost', 6, 0, FALSE, TRUE) v ON 1 = 1
|
||||
WHERE p.name = 'Sales Pipeline'
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_pipeline_stage)
|
||||
AND (SELECT COUNT(*) FROM crm_pipeline) = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_deal);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-5
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_lifecycle_stage WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_lifecycle_stage' AND LOWER(column_name) = 'tenant_key'
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_contact
|
||||
-- comment: Lifecycle stages. The CRM looks up Lead (new web leads) and Opportunity (Convert lead) by these exact names.
|
||||
INSERT INTO crm_lifecycle_stage (name, sort_order, is_active, description, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, v.d, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Subscriber' AS n, 1 AS so, 'Signed up for updates; not yet a lead.' AS d
|
||||
UNION ALL SELECT 'Lead', 2, 'Showed interest. Web leads start here.'
|
||||
UNION ALL SELECT 'Marketing Qualified Lead', 3, 'Engaged enough for marketing to hand over.'
|
||||
UNION ALL SELECT 'Sales Qualified Lead', 4, 'Accepted by sales as worth pursuing.'
|
||||
UNION ALL SELECT 'Opportunity', 5, 'Has an open deal. Convert lead moves the contact here.'
|
||||
UNION ALL SELECT 'Customer', 6, 'Has bought.'
|
||||
UNION ALL SELECT 'Evangelist', 7, 'A happy customer who refers others.'
|
||||
UNION ALL SELECT 'Other', 8, 'Does not fit the stages above.') v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_lifecycle_stage)
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_contact);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-6
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_lead_source WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_lead_source' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Where contacts and deals come from. Website is the source the CRM's web forms use.
|
||||
INSERT INTO crm_lead_source (name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Website' AS n, 1 AS so
|
||||
UNION ALL SELECT 'Referral', 2
|
||||
UNION ALL SELECT 'Event', 3
|
||||
UNION ALL SELECT 'Email Campaign', 4
|
||||
UNION ALL SELECT 'Social', 5
|
||||
UNION ALL SELECT 'Partner', 6
|
||||
UNION ALL SELECT 'Cold Outreach', 7
|
||||
UNION ALL SELECT 'Other', 8) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_lead_source);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-7
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_industry WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_industry' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: A short generic industry list for companies.
|
||||
INSERT INTO crm_industry (name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Technology' AS n, 1 AS so
|
||||
UNION ALL SELECT 'Healthcare', 2
|
||||
UNION ALL SELECT 'Financial Services', 3
|
||||
UNION ALL SELECT 'Manufacturing', 4
|
||||
UNION ALL SELECT 'Retail', 5
|
||||
UNION ALL SELECT 'Education', 6
|
||||
UNION ALL SELECT 'Real Estate', 7
|
||||
UNION ALL SELECT 'Professional Services', 8
|
||||
UNION ALL SELECT 'Nonprofit', 9
|
||||
UNION ALL SELECT 'Other', 10) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_industry);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-8
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_win_loss_reason WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_win_loss_reason' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Why deals are won (type 1) or lost (type 2). Names are unique across both types, so each carries its outcome as a prefix.
|
||||
INSERT INTO crm_win_loss_reason (name, type, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.t, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Won: Price' AS n, 1 AS t, 1 AS so
|
||||
UNION ALL SELECT 'Won: Product fit', 1, 2
|
||||
UNION ALL SELECT 'Won: Relationship', 1, 3
|
||||
UNION ALL SELECT 'Lost: Price', 2, 4
|
||||
UNION ALL SELECT 'Lost: Competitor', 2, 5
|
||||
UNION ALL SELECT 'Lost: No budget', 2, 6
|
||||
UNION ALL SELECT 'Lost: No decision', 2, 7
|
||||
UNION ALL SELECT 'Lost: Timing', 2, 8) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_win_loss_reason);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-9
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_contact_role WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_contact_role' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Roles a contact can play on a deal.
|
||||
INSERT INTO crm_contact_role (name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Decision Maker' AS n, 1 AS so
|
||||
UNION ALL SELECT 'Champion', 2
|
||||
UNION ALL SELECT 'Influencer', 3
|
||||
UNION ALL SELECT 'Economic Buyer', 4
|
||||
UNION ALL SELECT 'Technical Evaluator', 5
|
||||
UNION ALL SELECT 'End User', 6) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_contact_role);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-10
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_currency WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_currency' AND LOWER(column_name) = 'tenant_key'
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_deal
|
||||
-- comment: The base currency, only when none exists: amounts need it for their prefix and conversions. Change it before the first deal if the business does not report in USD.
|
||||
INSERT INTO crm_currency (currency_code, name, symbol, exchange_rate_to_base, is_base_currency, is_active, rate_last_updated_date, create_date, modify_date)
|
||||
SELECT 'USD', 'US Dollar', '$', 1, TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_currency WHERE currency_code = 'USD' OR is_base_currency = TRUE)
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_deal);
|
||||
-- rollback empty
|
||||
@@ -688,3 +688,9 @@ CREATE TABLE IF NOT EXISTS crm_job_state (
|
||||
last_success TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_full_pass TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
--changeset qbit-crm:crm_nullable_intake_names_and_owners
|
||||
ALTER TABLE crm_contact ALTER COLUMN first_name DROP NOT NULL;
|
||||
ALTER TABLE crm_contact ALTER COLUMN last_name DROP NOT NULL;
|
||||
ALTER TABLE crm_contact ALTER COLUMN owner_user_id DROP NOT NULL;
|
||||
ALTER TABLE crm_deal ALTER COLUMN owner_user_id DROP NOT NULL;
|
||||
+2129
-1405
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -137,7 +137,7 @@ class CrmAppMetaDataTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No retired trends; all widgets have explicit protection and full width.
|
||||
** No retired trends; all widgets have explicit protection and statistics use compact widths.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void widgets()
|
||||
@@ -147,7 +147,7 @@ class CrmAppMetaDataTest extends BaseTest
|
||||
for(QWidgetMetaDataInterface widget : instance.getWidgets().values())
|
||||
{
|
||||
assertThat(widget.getPermissionRules()).isNotNull();
|
||||
assertThat(widget.getGridColumns()).isEqualTo(12);
|
||||
assertThat(widget.getGridColumns()).isEqualTo(CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
|
||||
assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
* 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.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped starter defaults (db/liquibase/qbit-crm/crm-defaults.changelog.sql):
|
||||
** they apply to a fresh schema built from the qbit's own changelog, re-running
|
||||
** inserts nothing and keeps an administrator's edits, an existing setup is
|
||||
** respected, and a tenant-enabled schema is left alone.
|
||||
*******************************************************************************/
|
||||
public class CrmDefaultsChangelogTest
|
||||
{
|
||||
static final String SCHEMA = "/db/liquibase/qbit-crm/crm.changelog.sql";
|
||||
static final String TENANCY = "/db/liquibase/qbit-crm/crm-tenancy.changelog.sql";
|
||||
static final String CHANGELOG = "/db/liquibase/qbit-crm/crm-defaults.changelog.sql";
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables the changelog seeds.
|
||||
*******************************************************************************/
|
||||
public static final List<String> SEEDED_TABLES = List.of("crm_activity_type", "crm_activity_outcome", "crm_pipeline", "crm_pipeline_stage",
|
||||
"crm_lifecycle_stage", "crm_lead_source", "crm_industry", "crm_win_loss_reason", "crm_contact_role", "crm_currency");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh schema, seed twice (ignoring preconditions, so the SQL guards
|
||||
** themselves are tested): same rows; an edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAFreshSchemaInsertOnly() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection, true);
|
||||
Map<String, Integer> first = counts(connection);
|
||||
assertThat(first).containsEntry("crm_activity_type", 6).containsEntry("crm_activity_outcome", 5).containsEntry("crm_pipeline", 1)
|
||||
.containsEntry("crm_pipeline_stage", 6).containsEntry("crm_lifecycle_stage", 8).containsEntry("crm_lead_source", 8)
|
||||
.containsEntry("crm_win_loss_reason", 8).containsEntry("crm_contact_role", 6).containsEntry("crm_currency", 1);
|
||||
assertThat(first.get("crm_industry")).isEqualTo(10);
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE crm_pipeline_stage SET probability_pct = 15 WHERE name = 'Qualification'");
|
||||
statement.execute("UPDATE crm_currency SET name = 'Dollar' WHERE currency_code = 'USD'");
|
||||
}
|
||||
|
||||
runChangelog(connection, true);
|
||||
assertThat(counts(connection)).isEqualTo(first);
|
||||
assertThat(scalar(connection, "SELECT probability_pct FROM crm_pipeline_stage WHERE name = 'Qualification'")).isEqualTo("15");
|
||||
assertThat(scalar(connection, "SELECT name FROM crm_currency WHERE currency_code = 'USD'")).isEqualTo("Dollar");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seeded rows are what the CRM's code looks up and relies on.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsMatchWhatTheCodeNeeds() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE is_default = TRUE")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage s JOIN crm_pipeline p ON p.id = s.pipeline_id WHERE p.is_default = TRUE")).isEqualTo("6");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage WHERE is_closed_won = TRUE AND probability_pct = 100 AND name = 'Closed Won'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage WHERE is_closed_lost = TRUE AND probability_pct = 0 AND name = 'Closed Lost'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage WHERE name IN ('Lead', 'Opportunity')")).isEqualTo("2");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source WHERE name = 'Website'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT exchange_rate_to_base FROM crm_currency WHERE is_base_currency = TRUE AND currency_code = 'USD'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_win_loss_reason WHERE type = 1")).isEqualTo("3");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_win_loss_reason WHERE type = 2")).isEqualTo("5");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_activity_outcome o JOIN crm_activity_type t ON t.id = o.activity_type_id WHERE t.code = 'CALL'")).isEqualTo("3");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_activity_outcome o JOIN crm_activity_type t ON t.id = o.activity_type_id WHERE t.code = 'MEETING'")).isEqualTo("2");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that already has its own pipeline or base currency keeps it: the
|
||||
** default pipeline is not added, and a second base currency never appears.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void keepsAnExistingSetup() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO crm_pipeline (name, is_default, is_active) VALUES ('Mine', TRUE, TRUE)");
|
||||
statement.execute("INSERT INTO crm_currency (currency_code, name, is_base_currency) VALUES ('EUR', 'Euro', TRUE)");
|
||||
runChangelog(connection, true);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT currency_code FROM crm_currency")).isEqualTo("EUR");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_currency")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real thing: Liquibase applies the schema and then the defaults on H2;
|
||||
** a second update runs nothing; the rows are what the SQL-level tests saw.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void liquibaseAppliesTheDefaultsOnce() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE filename LIKE '%crm-defaults%' AND exectype = 'EXECUTED'")).isEqualTo("10");
|
||||
Map<String, Integer> first = counts(connection);
|
||||
assertThat(first).containsEntry("crm_pipeline", 1).containsEntry("crm_pipeline_stage", 6).containsEntry("crm_lifecycle_stage", 8);
|
||||
|
||||
connection.createStatement().execute("UPDATE crm_pipeline_stage SET probability_pct = 15 WHERE name = 'Qualification'");
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE filename LIKE '%crm-defaults%'")).isEqualTo("10");
|
||||
assertThat(counts(connection)).isEqualTo(first);
|
||||
assertThat(scalar(connection, "SELECT probability_pct FROM crm_pipeline_stage WHERE name = 'Qualification'")).isEqualTo("15");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that has not created the CRM tables yet does not fail: every
|
||||
** changeset is marked ran. A tenant-enabled schema is marked ran too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void liquibaseMarksRanWhenTablesAreMissingOrTenanted() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("10");
|
||||
}
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, TENANCY);
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE filename LIKE '%crm-defaults%' AND exectype = 'MARK_RAN'")).isEqualTo("10");
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM " + table)).as(table).isEqualTo("0");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rolling the defaults back deletes nothing: host-owned rows (and tenant rows,
|
||||
** whose changesets were marked ran) survive a Liquibase rollback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rollbackNeverDeletesHostRows() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, CHANGELOG);
|
||||
Map<String, Integer> seeded = counts(connection);
|
||||
connection.createStatement().execute("INSERT INTO crm_lead_source (name, sort_order) VALUES ('Host source', 99)");
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default) VALUES ('Host pipeline', FALSE)");
|
||||
connection.commit();
|
||||
rollback(connection, CHANGELOG, 10);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source WHERE name = 'Host source'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE name = 'Host pipeline'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source")).isEqualTo("9");
|
||||
assertThat(seeded.get("crm_pipeline_stage")).isEqualTo(6);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).isEqualTo("6");
|
||||
}
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, TENANCY);
|
||||
liquibase(connection, CHANGELOG);
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default, tenant_key) VALUES ('Sales Pipeline', TRUE, 'acme')");
|
||||
connection.createStatement().execute("INSERT INTO crm_lifecycle_stage (name, sort_order, tenant_key) VALUES ('Lead', 1, 'acme')");
|
||||
connection.commit();
|
||||
rollback(connection, CHANGELOG, 10);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE tenant_key = 'acme'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage WHERE tenant_key = 'acme'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle stages and the base currency change what intake, Convert lead
|
||||
** and amounts do, so they are seeded only on a fresh install; the pipeline
|
||||
** stages only into the pipeline the defaults created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void behaviorChangingSeedsNeedAFreshInstall() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
connection.createStatement().execute("INSERT INTO crm_contact (email) VALUES ('a@example.test')");
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source")).isEqualTo("8");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_currency")).isEqualTo("1");
|
||||
}
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
connection.createStatement().execute("INSERT INTO crm_deal (name, pipeline_id, pipeline_stage_id) VALUES ('d', 1, 1)");
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_currency")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage")).isEqualTo("8");
|
||||
}
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default) VALUES ('Sales Pipeline', TRUE)");
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default) VALUES ('Other', FALSE)");
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).as("not the only pipeline").isEqualTo("0");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Order matters: included before crm-tenancy, the untenanted rows are seeded
|
||||
** and the tenancy columns are added afterwards; included after, nothing is.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void orderAgainstTenancyMatters() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, CHANGELOG);
|
||||
liquibase(connection, TENANCY);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE tenant_key IS NULL")).as("wrong order: untenanted rows").isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void rollback(Connection connection, String path, int count) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(path.substring(1), new ClassLoaderResourceAccessor(), database).rollback(count, "crm-tenancy");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String url()
|
||||
{
|
||||
return ("jdbc:h2:mem:crmlb" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply one changelog (resource path with a leading slash) through Liquibase.
|
||||
*******************************************************************************/
|
||||
private static void liquibase(Connection connection, String path) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(path.substring(1), new ClassLoaderResourceAccessor(), database).update("crm-tenancy");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A tenant-enabled schema (tenant_key columns added) is skipped, honoring each
|
||||
** changeset's precondition: seeding without a tenant key would create rows no
|
||||
** tenant can see. A plain schema is not skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void skipsTenantEnabledTables() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements(resource(TENANCY)))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
runChangelog(connection, false);
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM " + table)).as(table).isEqualTo("0");
|
||||
}
|
||||
}
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every changeset guards itself: a precondition (a host without the table
|
||||
** marks it ran), an insert that skips rows that exist, and a rollback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyChangesetIsGuarded() throws Exception
|
||||
{
|
||||
List<String> changesets = changesets(resource(CHANGELOG));
|
||||
assertThat(changesets).hasSizeGreaterThanOrEqualTo(SEEDED_TABLES.size());
|
||||
for(String changeset : changesets)
|
||||
{
|
||||
assertThat(changeset).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- rollback ").contains("NOT EXISTS")
|
||||
.contains("-- precondition-sql-check expectedResult:0 ").doesNotContain("UPDATE ");
|
||||
}
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(resource(CHANGELOG)).contains("INSERT INTO " + table + " ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh H2 (PostgreSQL mode) with the qbit's own schema.
|
||||
*******************************************************************************/
|
||||
public static Connection freshSchema() throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:crmdefaults" + System.nanoTime() + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH");
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements(resource(SCHEMA)))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run each changeset's statements. With ignorePreconditions, the SQL runs
|
||||
** whatever the preconditions say; otherwise a changeset whose
|
||||
** precondition-sql-check does not give its expected result is skipped, as
|
||||
** Liquibase would (MARK_RAN).
|
||||
*******************************************************************************/
|
||||
public static void runChangelog(Connection connection, boolean ignorePreconditions) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String changeset : changesets(resource(CHANGELOG)))
|
||||
{
|
||||
if(!ignorePreconditions && !preconditionsHold(connection, changeset))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(String sql : statements(changeset))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean preconditionsHold(Connection connection, String changeset) throws Exception
|
||||
{
|
||||
Matcher m = Pattern.compile("(?m)^-- precondition-sql-check expectedResult:(\\d+) (.*)$").matcher(changeset);
|
||||
while(m.find())
|
||||
{
|
||||
if(!scalar(connection, m.group(2)).equals(m.group(1)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String resource(String path) throws Exception
|
||||
{
|
||||
try(InputStream stream = CrmDefaultsChangelogTest.class.getResourceAsStream(path))
|
||||
{
|
||||
assertThat(stream).as(path + " is on the classpath").isNotNull();
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text of each changeset (from its "-- changeset" line).
|
||||
*******************************************************************************/
|
||||
static List<String> changesets(String changelog)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String part : changelog.split("(?m)^(?=-- changeset )"))
|
||||
{
|
||||
if(part.startsWith("-- changeset "))
|
||||
{
|
||||
rs.add(part);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL statements, without comment lines, split at a ';' that ends a line.
|
||||
*******************************************************************************/
|
||||
static List<String> statements(String sql)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(String line : sql.split("\\R"))
|
||||
{
|
||||
if(!line.strip().startsWith("--"))
|
||||
{
|
||||
text.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String statement : text.toString().split(";\\s*\\n"))
|
||||
{
|
||||
if(!statement.isBlank())
|
||||
{
|
||||
rs.add(statement.strip().replaceAll(";$", ""));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Integer> counts(Connection connection) throws Exception
|
||||
{
|
||||
Map<String, Integer> rs = new LinkedHashMap<>();
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
rs.put(table, Integer.valueOf(scalar(connection, "SELECT COUNT(*) FROM " + table)));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String scalar(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
Object value = rs.getObject(1);
|
||||
return (value instanceof BigDecimal bd ? bd.stripTrailingZeros().toPlainString() : String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,11 +33,19 @@ import com.kingsrook.qbits.crm.health.CrmHealthChecks;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -48,27 +56,41 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class CrmHealthBulkLoadTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No transport or score run needs attention.
|
||||
** No transport or score run needs attention; the producer registered the
|
||||
** same checks with the core health registry.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noTransportOrScoreRunNeedsAttention() throws Exception
|
||||
{
|
||||
var checks = new CrmHealthChecks(new CrmQBitConfig());
|
||||
var results = checks.run();
|
||||
assertThat(results.get("crm.email").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
checks.indicators().forEach(indicator ->
|
||||
assertThat(results.get(CrmHealthChecks.EMAIL).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
for(HealthCheckMetaData registered : crmChecks(QContext.getQInstance()))
|
||||
{
|
||||
try
|
||||
{
|
||||
assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
assertThat(registered.getCheckInstance().run(new HealthCheckInput()).getStatus()).isEqualTo(results.get(registered.getName()).getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The producer registers exactly the three CRM checks as valid core
|
||||
** definitions, and re-registration does not duplicate them.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producerRegistersCoreHealthChecks()
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
assertThat(crmChecks(instance)).extracting(HealthCheckMetaData::getName)
|
||||
.containsExactlyInAnyOrder(CrmHealthChecks.EMAIL, CrmHealthChecks.SEQUENCE_LAG, CrmHealthChecks.LEAD_SCORE);
|
||||
assertThat(crmChecks(instance)).allSatisfy(check ->
|
||||
{
|
||||
assertThat(check.getCheckInstance()).isNotNull();
|
||||
assertThat(check.getTimeoutMs()).isLessThan(check.getIntervalSeconds() * 1000);
|
||||
});
|
||||
CrmHealthChecks.register(instance, new CrmQBitConfig());
|
||||
assertThat(crmChecks(instance)).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +120,8 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
.withValue("lastSuccess", Instant.now().minus(181, ChronoUnit.MINUTES)));
|
||||
com.kingsrook.qbits.crm.core.CrmRecords.update("crm_sequence_enrollment", new QRecord().withValue("id", 1)
|
||||
.withValue("nextStepDate", Instant.now().minus(31, ChronoUnit.MINUTES)));
|
||||
assertThat(checks.run().get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(checks.run().get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(checks.run().get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(checks.run().get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +139,7 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
throw (new IllegalStateException("unavailable"));
|
||||
}
|
||||
});
|
||||
assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(checks.check(CrmHealthChecks.EMAIL).run(new HealthCheckInput()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
@@ -160,30 +182,50 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Health middleware invokes checks in worker threads without a caller QContext.
|
||||
** The core HealthRunner runs each check on a worker thread with no caller
|
||||
** QContext, as a QSystemUserSession. A registered CRM check must succeed that
|
||||
** way on a host whose authentication rejects tokenless sessions (OAuth2, Auth0).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception
|
||||
void registeredChecksRunAsCoreSystemUserOnTokenRequiredHost() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1);
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("token-required")
|
||||
.withType(QAuthenticationType.MOCK)
|
||||
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
|
||||
HealthCheckMetaData sequenceLag = crmChecks(instance).stream()
|
||||
.filter(check -> CrmHealthChecks.SEQUENCE_LAG.equals(check.getName())).findFirst().orElseThrow();
|
||||
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
try
|
||||
{
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
var result = indicator.check(instance);
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
assertThat(QContext.getQSession()).isNull();
|
||||
return (result.getStatus());
|
||||
return (sequenceLag.getCheckInstance().run(new HealthCheckInput().withQInstance(instance)).getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}).get();
|
||||
assertThat(status).isEqualTo(HealthStatus.UP);
|
||||
assertThat(QContext.getQInstance()).isSameAs(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CRM definitions in the core health registry.
|
||||
*******************************************************************************/
|
||||
private static List<HealthCheckMetaData> crmChecks(QInstance instance)
|
||||
{
|
||||
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
|
||||
.filter(check -> "crm".equals(check.getComponent())).toList());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presentation contracts remain stable across all discovered CRM metadata.
|
||||
*******************************************************************************/
|
||||
class CrmPresentationTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every table has an explicit human label and user identifiers read naturally.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void labelsAndMoney()
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
assertThat(table.getLabel()).isNotBlank().doesNotContain("_").isNotEqualToIgnoringCase(table.getName());
|
||||
table.getFields().values().stream().filter(field -> field.getName().endsWith("UserId"))
|
||||
.forEach(field -> assertThat(field.getLabel()).doesNotContain("User Id"));
|
||||
}
|
||||
assertThat(instance.getTable("crm_contact").getLabel()).isEqualTo("Contacts");
|
||||
assertThat(instance.getTable("crm_deal").getField("amount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
assertThat(instance.getTable("crm_deal_product").getField("totalAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
assertThat(instance.getTable("crm_product").getField("unitPrice").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
assertThat(instance.getTable("crm_sales_goal").getField("targetAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
for(String table : List.of("crm_contact", "crm_deal"))
|
||||
{
|
||||
assertThat(instance.getTable(table).getField("ownerUserId").getIsRequired()).isFalse();
|
||||
assertThat(instance.getTable(table).getField("ownerUserId").getLabel()).isEqualTo("Owner");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All record-screen child lists have at most four valid, deliberate columns.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sectionsAndChildLists()
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var contact = instance.getTable("crm_contact");
|
||||
assertThat(contact.getSections()).extracting(section -> section.getName())
|
||||
.contains("details", "contactInformation", "attribution", "system").doesNotContain("online", "address", "communication", "data", "dates");
|
||||
assertThat(contact.getSections().stream().filter(section -> section.getName().equals("system")).findFirst().orElseThrow().getTier()).isEqualTo(Tier.T3);
|
||||
for(String parent : List.of("crm_contact", "crm_company", "crm_deal"))
|
||||
{
|
||||
instance.getTable(parent).getSections().stream().filter(section -> section.getWidgetName() != null).forEach(section ->
|
||||
{
|
||||
var widget = instance.getWidget(section.getWidgetName());
|
||||
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
|
||||
{
|
||||
var child = instance.getTable(instance.getJoin(joinName).getRightTable());
|
||||
List<?> fields = (List<?>) widget.getDefaultValues().get("onlyIncludeFieldNames");
|
||||
assertThat(fields).isNotEmpty().hasSizeLessThanOrEqualTo(4);
|
||||
fields.forEach(field -> assertThat(child.getFields()).containsKey(field.toString()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A computed label must not re-expose fields omitted by the record view.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hiddenNamesStayOutOfLabels() throws Exception
|
||||
{
|
||||
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
|
||||
.withValue("firstName", "Private").withValue("lastName", "Name").withValue("email", "visible@example.test"))).getRecords().getFirst();
|
||||
var table = QContext.getQInstance().getTable("crm_contact");
|
||||
table.getField("firstName").setIsHidden(true);
|
||||
table.getField("lastName").setIsHidden(true);
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput("crm_contact").withPrimaryKey(inserted.getValue("id"));
|
||||
input.setShouldOmitHiddenFields(true);
|
||||
QRecord visible = new GetAction().execute(input).getRecord();
|
||||
assertThat(visible.getRecordLabel()).isEqualTo("visible@example.test");
|
||||
assertThat(visible.getValue("firstName")).isNull();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing names remain null while get and query return useful record labels.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contactNameFallbacks() throws Exception
|
||||
{
|
||||
for(String[] values : new String[][] {{"Ada", "Lovelace", "Ada Lovelace"}, {"Ada", null, "Ada"}, {null, null, "ada@example.test"}})
|
||||
{
|
||||
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
|
||||
.withValue("firstName", values[0]).withValue("lastName", values[1]).withValue("email", "ada@example.test"))).getRecords().getFirst();
|
||||
assertThat(inserted.getErrors()).isNullOrEmpty();
|
||||
QRecord record = GetAction.execute("crm_contact", inserted.getValue("id"));
|
||||
assertThat(record.getRecordLabel()).isEqualTo(values[2]);
|
||||
assertThat(record.getValue("lastName")).isEqualTo(values[1]);
|
||||
}
|
||||
assertThat(new QueryAction().execute(new QueryInput("crm_contact")).getRecords()).extracting(QRecord::getRecordLabel)
|
||||
.containsExactly("Ada Lovelace", "Ada", "ada@example.test");
|
||||
assertThat(ContactLabelCustomizer.label(new QRecord().withValue("firstName", " ").withValue("lastName", " ").withValue("email", "mail@example.test")))
|
||||
.isEqualTo("mail@example.test");
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.crm;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction;
|
||||
import com.kingsrook.qqq.api.model.APIVersion;
|
||||
import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput;
|
||||
@@ -33,8 +32,8 @@ import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -101,52 +100,29 @@ class CrmPublicationHelpTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help covers app every table main field process and enum.
|
||||
** Native registration is safe without help tables and covers all CRM metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception
|
||||
void helpCoversOwnedMetadataWithoutHelpTable()
|
||||
{
|
||||
var entries = CrmHelpBundleStandIn.entries();
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(entries).containsKey("app:crm;slot:help");
|
||||
for(var table : instance.getTables().values())
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(entries).containsKey("table:" + table.getName() + ";slot:help");
|
||||
assertThat(table.getHelpContent().get("help")).isNotEmpty();
|
||||
if(CrmAppMetaData.MAIN_TABLES.contains(table.getName()))
|
||||
{
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName());
|
||||
assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var process : instance.getProcesses().values())
|
||||
{
|
||||
if(process.getName().startsWith("crm_"))
|
||||
{
|
||||
assertThat(entries).containsKey("process:" + process.getName() + ";slot:help");
|
||||
// Core creates dotted bulk-process names after QBit production; they retain core screen help.
|
||||
if(!process.getName().contains("."))
|
||||
{
|
||||
process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast)
|
||||
.forEach(step -> assertThat(step.getHelpContents()).isNotEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var pvs : instance.getPossibleValueSources().values())
|
||||
{
|
||||
if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm"))
|
||||
{
|
||||
assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help");
|
||||
}
|
||||
}
|
||||
entries.values().forEach(entry ->
|
||||
{
|
||||
assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank();
|
||||
assertThat(entry.get("content").asText()).isNotBlank();
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-crm");
|
||||
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
|
||||
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
|
||||
});
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
.bundles("help/qbit-crm.yaml")
|
||||
.owning(target -> target.name().equals("crm") || target.name().startsWith("crm_") || target.name().startsWith("Crm"))
|
||||
.assertComplete();
|
||||
// Core's gate validates these entries but does not require widget or possible-value coverage.
|
||||
var entries = HelpBundles.of(instance).resolveWinningEntries();
|
||||
instance.getWidgets().keySet().stream().filter(name -> name.startsWith("crm_")).forEach(name ->
|
||||
assertThat(entries).containsKey(new HelpBundles.EntryKey("widget:" + name + ";slot:label", "ALL_SCREENS")));
|
||||
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("crm_") || name.startsWith("Crm")).forEach(name ->
|
||||
assertThat(entries).containsKey(new HelpBundles.EntryKey("possibleValueSource:" + name + ";slot:help", "ALL_SCREENS")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class CrmQBitProducerTest extends BaseTest
|
||||
assertThat(metaData).isNotNull();
|
||||
assertThat(metaData.getGroupId()).isEqualTo(CrmQBitProducer.GROUP_ID);
|
||||
assertThat(metaData.getArtifactId()).isEqualTo(CrmQBitProducer.ARTIFACT_ID);
|
||||
assertThat(metaData.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
|
||||
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class CrmQBitProducerTest extends BaseTest
|
||||
{
|
||||
assertThat(CrmQBitProducer.GROUP_ID).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(CrmQBitProducer.ARTIFACT_ID).isEqualTo("crm");
|
||||
assertThat(CrmQBitProducer.VERSION).isEqualTo("0.2.0-SNAPSHOT");
|
||||
assertThat(CrmQBitProducer.VERSION).isEqualTo(System.getProperty("qbit.artifactVersion"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -270,20 +270,41 @@ class CrmRdbmsTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both install paths contain identical SQL, with one Liquibase changeset per table.
|
||||
** Fresh DDL and the full upgrade changelog produce identical column definitions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testLiquibaseMatchesReferenceDdl() throws Exception
|
||||
{
|
||||
String ddl = Files.readString(Path.of("docs/ddl/postgres.sql"));
|
||||
String changelog = Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql"));
|
||||
assertThat(changelog).startsWith("--liquibase formatted sql");
|
||||
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(34);
|
||||
assertThat(stripComments(changelog)).isEqualTo(stripComments(ddl));
|
||||
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(35);
|
||||
var freshColumns = schemaColumns();
|
||||
executeSql("DROP ALL OBJECTS;");
|
||||
executeSql(changelog);
|
||||
assertThat(schemaColumns()).isEqualTo(freshColumns);
|
||||
insert("crm_contact", new QRecord().withValue("email", "email-only@example.test"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture persisted types and nullability for both installation paths.
|
||||
*******************************************************************************/
|
||||
private java.util.Map<String, String> schemaColumns() throws Exception
|
||||
{
|
||||
java.util.Map<String, String> columns = new java.util.TreeMap<>();
|
||||
try(ResultSet rows = connection.getMetaData().getColumns(null, "public", "crm_%", null))
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
columns.put(rows.getString("TABLE_NAME") + "." + rows.getString("COLUMN_NAME"),
|
||||
rows.getString("TYPE_NAME") + ":" + rows.getInt("COLUMN_SIZE") + ":" + rows.getInt("NULLABLE"));
|
||||
}
|
||||
}
|
||||
return (columns);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert through QQQ and fail on record-level validation errors.
|
||||
*******************************************************************************/
|
||||
@@ -359,7 +380,7 @@ class CrmRdbmsTest
|
||||
input.setWidgetMetaData(qInstance.getWidget("crm_pipeline_summary"));
|
||||
TableData pipeline = (TableData) renderer.execute(input).getWidgetData();
|
||||
assertThat(pipeline.getRows()).hasSize(2);
|
||||
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open (1 deals)</a>");
|
||||
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open</a>");
|
||||
input.setWidgetMetaData(qInstance.getWidget("crm_deal_forecast"));
|
||||
TableData forecast = (TableData) renderer.execute(input).getWidgetData();
|
||||
assertThat(forecast.getRows()).hasSize(6);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: every table populated, used and
|
||||
** self-explaining). For every crm_* table:
|
||||
** - runtime code (not just its model, metadata or setup card) reads or writes
|
||||
** it, or the table is listed here as having no runtime code and why;
|
||||
** - it is either seeded by the shipped defaults changelog, or listed here
|
||||
** with why it starts empty;
|
||||
** - it has bundle help that says how its rows get created (shown on the table
|
||||
** screen and when it is empty).
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class CrmTablesGuardrailTest extends BaseTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "crm");
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata and presentation files: naming a table here is not using it.
|
||||
*******************************************************************************/
|
||||
private static final Set<String> NOT_RUNTIME = Set.of("CrmAppMetaData.java", "CrmPresentation.java", "CrmPermissions.java",
|
||||
"CrmDataPolicy.java", "CrmSetupStatus.java", "CrmIntegrations.java");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("crm_contact", "user content: people, imports, converted web leads"),
|
||||
Map.entry("crm_company", "user content"),
|
||||
Map.entry("crm_deal", "user content: New, or Create Deal on a contact"),
|
||||
Map.entry("crm_deal_contact", "user content: Add Contact on a deal"),
|
||||
Map.entry("crm_deal_product", "user content: product lines on a deal"),
|
||||
Map.entry("crm_deal_stage_history", "runtime: a deal is created or changes stage"),
|
||||
Map.entry("crm_activity", "user content: Log Activity"),
|
||||
Map.entry("crm_activity_participant", "user content: people added to an activity"),
|
||||
Map.entry("crm_form_submission", "runtime: the host's public intake call"),
|
||||
Map.entry("crm_consent_record", "runtime: evidence written when a consent decision is made"),
|
||||
Map.entry("crm_email_list", "user content: lists are the business's own"),
|
||||
Map.entry("crm_email_list_member", "runtime: subscribing a contact or address"),
|
||||
Map.entry("crm_email_message", "runtime: every email sent or received"),
|
||||
Map.entry("crm_email_template", "user content: the wording is the business's own"),
|
||||
Map.entry("crm_email_sequence", "user content: the outreach is the business's own"),
|
||||
Map.entry("crm_sequence_step", "user content: steps of a sequence"),
|
||||
Map.entry("crm_sequence_enrollment", "runtime: Enroll in Sequence"),
|
||||
Map.entry("crm_product", "user content: the catalogue is the business's own"),
|
||||
Map.entry("crm_sales_goal", "user content: targets per user and period"),
|
||||
Map.entry("crm_lead_score_rule", "business-specific: good scoring depends on the business"),
|
||||
Map.entry("crm_assignment_rule", "host-specific: rules name the host's users"),
|
||||
Map.entry("crm_assignment_rule_member", "host-specific: the host's users"),
|
||||
Map.entry("crm_job_state", "runtime: a scheduled maintenance run saves its checkpoint"),
|
||||
Map.entry("crm_external_mapping", "not wired: nothing in the CRM writes it (reserved for a host integration)"));
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables no CRM code reads or writes: their rows are the user's own, added
|
||||
** and edited through the standard table screens and the published API.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> USER_OWNED_NO_CODE = Map.of(
|
||||
"crm_activity_participant", "user-owned child rows added on an activity's record",
|
||||
"crm_product", "user-owned catalogue; deal product lines pick from it (possible values, line item screens)",
|
||||
"crm_industry", "reference list consumed only through the Industry picker on a company (possible values)");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables with no writer in the CRM; their help must say so.
|
||||
*******************************************************************************/
|
||||
static final Set<String> NOT_WRITTEN_YET = Set.of("crm_external_mapping");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java")).filter(p -> !p.toString().replace('\\', '/').contains("/model/"))
|
||||
.filter(p -> !NOT_RUNTIME.contains(p.getFileName().toString())).map(CrmTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : crmTables())
|
||||
{
|
||||
if(USER_OWNED_NO_CODE.containsKey(table))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String entity = entityByTable.get(table);
|
||||
Pattern entityUse = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
|
||||
assertThat(entity).as("entity class for " + table).isNotNull();
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> source.contains("\"" + table + "\"") || entityUse.matcher(source).find()))
|
||||
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
String changelog = CrmDefaultsChangelogTest.resource(CrmDefaultsChangelogTest.CHANGELOG);
|
||||
for(String table : crmTables())
|
||||
{
|
||||
boolean seeded = CrmDefaultsChangelogTest.SEEDED_TABLES.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance().getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
HelpBundles.of(qInstance()).getBundles().getFirst().getEntries()
|
||||
.forEach(entry -> assertThat(entry.getSummary()).as(entry.getKey()).hasSizeLessThanOrEqualTo(120));
|
||||
for(String table : crmTables())
|
||||
{
|
||||
assertThat(tableHelp(table)).as(table + " help says how rows get created").contains("**How rows get created:**");
|
||||
}
|
||||
for(String table : NOT_WRITTEN_YET)
|
||||
{
|
||||
assertThat(tableHelp(table)).as(table + " says it is not used yet").contains("not used yet");
|
||||
}
|
||||
assertThat(HelpBundles.of(qInstance()).resolveWinningEntries()).containsKey(new HelpBundles.EntryKey("app:crm;slot:help", "ALL_SCREENS"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables seeded by the changelog are the ones the help says are seeded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpOfSeededTablesSaysSo() throws Exception
|
||||
{
|
||||
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
|
||||
{
|
||||
assertThat(tableHelp(table)).as(table).contains("seeds");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the winning table entry from the registered native bundle.
|
||||
*******************************************************************************/
|
||||
private String tableHelp(String table)
|
||||
{
|
||||
var entry = HelpBundles.of(qInstance()).resolveWinningEntries().get(new HelpBundles.EntryKey("table:" + table + ";slot:help", "ALL_SCREENS"));
|
||||
assertThat(entry).as(table + " table help").isNotNull();
|
||||
return (entry.entry().getContent());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test instance after CRM production.
|
||||
*******************************************************************************/
|
||||
private com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance()
|
||||
{
|
||||
return (com.kingsrook.qqq.backend.core.context.QContext.getQInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> crmTables()
|
||||
{
|
||||
List<String> tables = qInstance().getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("crm_")).sorted().toList();
|
||||
assertThat(tables).hasSize(34);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** model/X.java declares TABLE_NAME = "x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> entityByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
for(Path file : files.filter(f -> f.toString().endsWith(".java")).toList())
|
||||
{
|
||||
Matcher m = p.matcher(read(file));
|
||||
if(m.find())
|
||||
{
|
||||
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test authentication module that behaves like the token-based core modules
|
||||
** (OAuth2, Auth0): a QSystemUserSession is always valid, a session it created
|
||||
** is valid, and any other session has no token and fails validation with the
|
||||
** same message the OAuth2 module produces (JWTDecodeException: The token is
|
||||
** null).
|
||||
*******************************************************************************/
|
||||
public class TokenRequiredAuthenticationModule implements QAuthenticationModuleInterface
|
||||
{
|
||||
private static final Set<String> ISSUED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue a session that carries a token.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QSession createSession(QInstance qInstance, Map<String, String> context) throws QAuthenticationException
|
||||
{
|
||||
QSession session = new QSession();
|
||||
ISSUED.add(session.getUuid());
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept the system user and issued sessions; reject tokenless sessions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isSessionValid(QInstance instance, QSession session)
|
||||
{
|
||||
if(session instanceof QSystemUserSession || ISSUED.contains(session.getUuid()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
throw (new IllegalStateException("The token is null"));
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,9 @@ class CrmActionsTest extends BaseTest
|
||||
put("crm_contact", new QRecord().withValue("id", 10 + i * 2).withValue("lastName", "Contact").withValue("ownerUserId", "original"));
|
||||
}
|
||||
PageObserver.sizes.clear();
|
||||
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class));
|
||||
QContext.getQInstance().getTable("crm_contact").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer.of(
|
||||
new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class), new QCodeReference(PageObserver.class)));
|
||||
RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_calculate_lead_scores"));
|
||||
assertThat(result.getValues().get("totalContacts")).isEqualTo(405);
|
||||
assertThat(result.getValues().get("updatedCount")).isEqualTo(405);
|
||||
|
||||
@@ -99,6 +99,23 @@ public class CrmApisTest extends BaseTest
|
||||
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Email-only intake preserves absent names and never assigns the service user.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emailOnlyIntakeHasNoOwner() throws Exception
|
||||
{
|
||||
QContext.getQSession().setIdReference("site-api-system");
|
||||
CrmFormResult result = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, null, " ", "only@example.test",
|
||||
null, null, "Hello", false, "Reply", null, null, null, null, null, null, null, null, null));
|
||||
QRecord contact = CrmStore.asUser().get("crm_contact", result.contactId());
|
||||
assertThat(contact.getValue("firstName")).isNull();
|
||||
assertThat(contact.getValue("lastName")).isNull();
|
||||
assertThat(contact.getValue("ownerUserId")).isNull();
|
||||
assertThat(contact.getRecordLabel()).isEqualTo("only@example.test");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Form preserves first touch and never grants implicit marketing consent.
|
||||
*******************************************************************************/
|
||||
@@ -530,7 +547,7 @@ public class CrmApisTest extends BaseTest
|
||||
assertThat(CrmStore.trusted().by("crm_contact", "email", "subscriber@example.test").get(0).getValueString("ownerUserId")).isEqualTo("sales-0");
|
||||
QContext.getQSession().setIdReference("named-owner");
|
||||
Integer owned = intake.submitForm(form("owned@example.test", false, "owned")).contactId();
|
||||
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("named-owner");
|
||||
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("sales-1");
|
||||
CrmStore.trusted().update("crm_contact", new QRecord().withValue("id", first.contactId()).withValue("ownerUserId", "system"));
|
||||
intake.submitForm(form("first@example.test", false, "existing-unowned"));
|
||||
assertThat(CrmStore.trusted().get("crm_contact", first.contactId()).getValueString("ownerUserId")).isEqualTo("system");
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.CrmDefaultsChangelogTest;
|
||||
import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.widgets.CrmSetupStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped defaults change behavior: with exactly the rows the changelog
|
||||
** inserts (applied to H2, then copied into the test backend), Convert lead
|
||||
** works and moves the contact to Opportunity, intake gives a new lead the Lead
|
||||
** stage, and the Setup card has nothing left to do.
|
||||
*******************************************************************************/
|
||||
class CrmDefaultsBehaviorTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsMakeTheCrmWork() throws Exception
|
||||
{
|
||||
loadSeededRows();
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// intake: a new web lead is assigned the seeded "Lead" stage //
|
||||
////////////////////////////////////////////////////////////////
|
||||
CrmIntakeApi intake = new CrmIntakeApi(CrmApi.config(), new CrmEmailTransport(CrmApi.config()));
|
||||
Integer contactId = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, "Ada", "Lovelace", "ada@example.test", "555-0100",
|
||||
"Example", "Hello", false, "Reply", "search", "organic", "c", "banner", "crm", "https://example.test/form", "https://search.example.test",
|
||||
"a".repeat(64), "b".repeat(64))).contactId();
|
||||
QRecord lead = CrmStore.asUser().get("crm_contact", contactId);
|
||||
Integer leadStageId = CrmStore.asUser().by("crm_lifecycle_stage", "name", "Lead").getFirst().getValueInteger("id");
|
||||
assertThat(lead.getValueInteger("lifecycleStageId")).isEqualTo(leadStageId);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Convert lead: finds the default pipeline and its first stage, and moves the //
|
||||
// contact to the seeded "Opportunity" stage //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
RunBackendStepInput input = new RunBackendStepInput();
|
||||
input.setProcessName(ConvertLeadProcess.NAME);
|
||||
input.addValue("contactId", contactId);
|
||||
input.addValue("dealName", "Ada deal");
|
||||
input.addValue("dealAmount", new BigDecimal("1000"));
|
||||
RunBackendStepOutput output = runBusinessStep(input);
|
||||
QRecord deal = CrmStore.asUser().get("crm_deal", output.getValueInteger("dealId"));
|
||||
assertThat(deal).isNotNull();
|
||||
QRecord firstStage = CrmStore.asUser().by("crm_pipeline_stage", "name", "Qualification").getFirst();
|
||||
assertThat(deal.getValueInteger("pipelineStageId")).isEqualTo(firstStage.getValueInteger("id"));
|
||||
Integer opportunityId = CrmStore.asUser().by("crm_lifecycle_stage", "name", "Opportunity").getFirst().getValueInteger("id");
|
||||
assertThat(CrmStore.asUser().get("crm_contact", contactId).getValueInteger("lifecycleStageId")).isEqualTo(opportunityId);
|
||||
assertThat(deal.getValueBigDecimal("amountInBaseCurrency")).isEqualByComparingTo("1000");
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// setup card: nothing left to do for the five tracked items //
|
||||
///////////////////////////////////////////////////////////////
|
||||
List<CrmSetupStatus.Item> items = CrmSetupStatus.load();
|
||||
assertThat(items).extracting(CrmSetupStatus.Item::state).doesNotContain(CrmSetupStatus.State.TODO);
|
||||
assertThat(items).filteredOn(item -> !item.label().equals("Email delivery")).extracting(CrmSetupStatus.Item::state).containsOnly(CrmSetupStatus.State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the defaults to a fresh H2 schema and insert every row it created
|
||||
** into the test backend, preserving ids.
|
||||
*******************************************************************************/
|
||||
private void loadSeededRows() throws Exception
|
||||
{
|
||||
try(Connection connection = CrmDefaultsChangelogTest.freshSchema())
|
||||
{
|
||||
CrmDefaultsChangelogTest.runChangelog(connection, false);
|
||||
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT * FROM " + table + " ORDER BY id"))
|
||||
{
|
||||
ResultSetMetaData meta = rs.getMetaData();
|
||||
while(rs.next())
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
for(int i = 1; i <= meta.getColumnCount(); i++)
|
||||
{
|
||||
String column = meta.getColumnLabel(i).toLowerCase();
|
||||
if(column.equals("create_date") || column.equals("modify_date") || rs.getObject(i) == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
record.setValue(camel(column), (java.io.Serializable) rs.getObject(i));
|
||||
}
|
||||
var result = new InsertAction().execute(new InsertInput(table).withRecord(record));
|
||||
assertThat(result.getRecords().getFirst().getErrors()).as(table).isNullOrEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String camel(String snake)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean upper = false;
|
||||
for(char c : snake.toCharArray())
|
||||
{
|
||||
if(c == '_')
|
||||
{
|
||||
upper = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append(upper ? Character.toUpperCase(c) : c);
|
||||
upper = false;
|
||||
}
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class ContactTest extends BaseTest
|
||||
///////////////////////////////////
|
||||
// verify T1 identity section //
|
||||
///////////////////////////////////
|
||||
assertThat(table.getSections().get(0).getFieldNames()).contains("id", "firstName", "lastName", "email", "phone");
|
||||
assertThat(table.getSections().get(0).getFieldNames()).contains("firstName", "lastName", "email", "phone");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
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.UpdateAction;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** crm_currency.rateLastUpdatedDate says when the exchange rate was last set or
|
||||
** changed: stamped on insert, moved by a changed rate, left alone by an edit
|
||||
** that does not touch the rate.
|
||||
*******************************************************************************/
|
||||
class CurrencyRateDateTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stampsOnInsertAndOnlyMovesWhenTheRateChanges() throws Exception
|
||||
{
|
||||
Instant before = Instant.now().minusSeconds(1);
|
||||
Integer id = new InsertAction().execute(new InsertInput("crm_currency").withRecord(new QRecord()
|
||||
.withValue("currencyCode", "EUR").withValue("name", "Euro").withValue("exchangeRateToBase", new BigDecimal("1.10"))))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
Instant stamped = GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate");
|
||||
assertThat(stamped).as("insert stamps the date").isAfter(before);
|
||||
|
||||
Thread.sleep(20);
|
||||
new UpdateAction().execute(new UpdateInput("crm_currency").withRecord(new QRecord().withValue("id", id).withValue("name", "Euro (EU)")));
|
||||
assertThat(GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate")).as("a rename leaves the date").isEqualTo(stamped);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput("crm_currency").withRecord(new QRecord().withValue("id", id).withValue("exchangeRateToBase", new BigDecimal("1.100000"))));
|
||||
assertThat(GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate")).as("the same rate leaves the date").isEqualTo(stamped);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput("crm_currency").withRecord(new QRecord().withValue("id", id).withValue("exchangeRateToBase", new BigDecimal("1.25"))));
|
||||
assertThat(GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate")).as("a new rate moves the date").isAfter(stamped);
|
||||
}
|
||||
|
||||
}
|
||||
+24
@@ -119,6 +119,30 @@ class DealInitializationCustomizerTest extends BaseTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lost-stage inserts close immediately while imported dates are preserved.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void closedStageDates() throws QException
|
||||
{
|
||||
Integer pipeline = insertPipeline("Closed imports");
|
||||
Integer stage = new InsertAction().execute(new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(new PipelineStage()
|
||||
.withPipelineId(pipeline).withName("Lost").withSortOrder(1).withProbabilityPct(0).withIsClosedLost(true)))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
Deal lost = new Deal(GetAction.execute(Deal.TABLE_NAME, insertDeal("Lost", pipeline, stage, BigDecimal.TEN)));
|
||||
assertEquals(java.time.LocalDate.now(java.time.ZoneOffset.UTC), lost.getActualCloseDate());
|
||||
assertNotNull(lost.getStageEnteredDate());
|
||||
java.time.LocalDate closeDate = java.time.LocalDate.of(2025, 1, 2);
|
||||
java.time.Instant entered = java.time.Instant.parse("2025-01-01T10:00:00Z");
|
||||
Integer imported = new InsertAction().execute(new InsertInput(Deal.TABLE_NAME).withRecordEntity(new Deal()
|
||||
.withName("Imported").withPipelineId(pipeline).withPipelineStageId(stage).withActualCloseDate(closeDate).withStageEnteredDate(entered)))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
Deal saved = new Deal(GetAction.execute(Deal.TABLE_NAME, imported));
|
||||
assertEquals(closeDate, saved.getActualCloseDate());
|
||||
assertEquals(entered, saved.getStageEnteredDate());
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Helper methods //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -64,6 +64,26 @@ class CrmContactTimelineWidgetTest extends BaseTest
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session zones observe DST; missing or invalid zones fall back to UTC.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readableDates()
|
||||
{
|
||||
Instant time = Instant.parse("2026-10-14T20:19:01.142263Z");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
|
||||
QContext.getQSession().setValue("UserTimezone", "America/Chicago");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 3:19 PM");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(Instant.parse("2026-01-14T20:19:00Z"))).isEqualTo("Jan 14, 2026 2:19 PM");
|
||||
QContext.getQSession().setValue("UserTimezone", "invalid");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
|
||||
QContext.getQSession().setValue("UserTimezone", "");
|
||||
QContext.getQSession().setValue("UserTimezoneOffsetMinutes", "120");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 10:19 PM");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(null)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing and nonexistent parent ids never query all contacts' histories.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -82,6 +82,54 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whole and fractional amounts retain grouping without artificial decimals.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void amountFormatting()
|
||||
{
|
||||
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("72000.000000"))).isEqualTo("72,000");
|
||||
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("1234.560000"))).isEqualTo("1,234.56");
|
||||
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("-1234.005"))).isEqualTo("-1,234.005");
|
||||
assertThat(CrmHomeRenderer.formatAmount(null)).isEqualTo("0");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A direct won-stage insert participates in actual-close reporting immediately.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void directWonInsert() throws Exception
|
||||
{
|
||||
seedStages();
|
||||
seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
|
||||
var inserted = new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(new InsertInput("crm_deal")
|
||||
.withRecord(new QRecord().withValue("name", "Direct win").withValue("pipelineId", 1).withValue("pipelineStageId", 2)
|
||||
.withValue("amount", new BigDecimal("72000")).withValue("expectedCloseDate", LocalDate.now().plusYears(1))));
|
||||
assertThat(inserted.getRecords().getFirst().getErrors()).isNullOrEmpty();
|
||||
var deal = com.kingsrook.qqq.backend.core.actions.tables.GetAction.execute("crm_deal", inserted.getRecords().getFirst().getValue("id"));
|
||||
assertThat(deal.getValue("ownerUserId")).isNull();
|
||||
assertThat(deal.getValueLocalDate("actualCloseDate")).isEqualTo(LocalDate.now(ZoneOffset.UTC));
|
||||
assertThat(deal.getValueInstant("stageEnteredDate")).isNotNull();
|
||||
assertThat(((StatisticsData) render("crm_won_month")).getCount()).isEqualTo("USD 72,000");
|
||||
TableData leaderboard = (TableData) render("crm_sales_leaderboard");
|
||||
assertCell(leaderboard, 0, "first", "Unassigned");
|
||||
assertCell(leaderboard, 0, "second", "1");
|
||||
assertCell(leaderboard, 0, "third", "72,000");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A partial submission name uses the email to retain identifying context.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void partialLeadName() throws Exception
|
||||
{
|
||||
seed("crm_form_submission", new QRecord().withValue("firstName", "Lee").withValue("email", "lee@example.test"));
|
||||
assertCell((TableData) render("crm_recent_web_leads"), 0, "first", "lee@example.test");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty data renders zero statistics, six months and no goals card.
|
||||
*******************************************************************************/
|
||||
@@ -92,7 +140,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
{
|
||||
assertStatistic(name, "0");
|
||||
}
|
||||
for(String name : CrmHomeWidgets.HOME.subList(1, CrmHomeWidgets.HOME.size()))
|
||||
for(String name : CrmHomeWidgets.HOME.subList(1, CrmHomeWidgets.HOME.size()).stream().filter(name -> !name.equals("crm_setup")).toList())
|
||||
{
|
||||
TableData data = (TableData) render(name);
|
||||
assertThat(data.getRows()).hasSize(name.equals("crm_deal_forecast") ? 6 : 0);
|
||||
@@ -119,16 +167,16 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
TableData data = (TableData) render("crm_pipeline_summary");
|
||||
assertThat(data.getColumns()).hasSize(3);
|
||||
assertThat(data.getRows()).hasSize(3);
|
||||
assertCell(data, 0, "first", "Open (2 deals)");
|
||||
assertCell(data, 0, "second", "300.00");
|
||||
assertCell(data, 0, "third", "40.50");
|
||||
assertCell(data, 1, "first", "Won (0 deals)");
|
||||
assertCell(data, 0, "first", "Open");
|
||||
assertCell(data, 0, "second", "2");
|
||||
assertCell(data, 0, "third", "40.5");
|
||||
assertCell(data, 1, "first", "Won");
|
||||
assertThat(data.getDropdownNameList()).containsExactly("pipelineId");
|
||||
RenderWidgetInput selected = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_pipeline_summary"));
|
||||
selected.addQueryParam("pipelineId", "2");
|
||||
TableData other = (TableData) new CrmHomeRenderer().render(selected).getWidgetData();
|
||||
assertThat(other.getRows()).hasSize(1);
|
||||
assertCell(other, 0, "first", "Other (1 deals)");
|
||||
assertCell(other, 0, "first", "Other");
|
||||
selected.addQueryParam("pipelineId", "999");
|
||||
assertThat(((TableData) new CrmHomeRenderer().render(selected).getWidgetData()).getRows()).hasSize(3);
|
||||
assertLinks(data);
|
||||
@@ -223,7 +271,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_goals")).isFalse();
|
||||
assertThat(data.getRows()).hasSize(1);
|
||||
assertCell(data, 0, "first", TEST_USER_ID + " (50.0%)");
|
||||
assertCell(data, 0, "second", "500.50");
|
||||
assertCell(data, 0, "second", "500.5");
|
||||
assertCell(data, 0, "third", "250.25");
|
||||
assertLinks(data);
|
||||
}
|
||||
@@ -247,7 +295,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
assertThat(data.getRows()).hasSize(2);
|
||||
assertCell(data, 0, "first", "Ada");
|
||||
assertCell(data, 0, "second", "2");
|
||||
assertCell(data, 0, "third", "251.00");
|
||||
assertCell(data, 0, "third", "251");
|
||||
assertLinks(data);
|
||||
}
|
||||
|
||||
@@ -331,7 +379,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
*******************************************************************************/
|
||||
private void assertStatistic(String name, String expected) throws QException
|
||||
{
|
||||
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString())).isEqualByComparingTo(expected);
|
||||
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString().replace(",", ""))).isEqualByComparingTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: what is missing is called out with its consequence, what is
|
||||
** in place is marked set up, and the card renders on the home.
|
||||
*******************************************************************************/
|
||||
class CrmSetupStatusTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Nothing configured: the business setup the CRM needs are flagged.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyInstallNeedsSetup() throws Exception
|
||||
{
|
||||
Map<String, CrmSetupStatus.Item> items = items();
|
||||
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency");
|
||||
assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails");
|
||||
assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity");
|
||||
assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A default pipeline without a closed-won or closed-lost stage still needs setup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pipelineNeedsClosedStages() throws Exception
|
||||
{
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
|
||||
assertThat(items().get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.OK);
|
||||
assertThat(items().get("Pipeline stages").detail()).contains("no stages");
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("pipelineId", 1).withValue("name", "Open").withValue("sortOrder", 1));
|
||||
assertThat(items().get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items().get("Pipeline stages").detail()).contains("no Closed Won");
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
|
||||
assertThat(items().get("Pipeline stages").detail()).contains("no Closed Lost");
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
|
||||
assertThat(items().get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle stages name exactly what is missing; currency needs a base.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lifecycleAndCurrency() throws Exception
|
||||
{
|
||||
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
|
||||
assertThat(items().get("Lifecycle stages").detail()).contains("Missing Opportunity").doesNotContain("web leads");
|
||||
assertThat(items().get("Lifecycle stages").nextStep()).contains("\"Opportunity\"");
|
||||
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
|
||||
assertThat(items().get("Lifecycle stages").state()).isEqualTo(CrmSetupStatus.State.OK);
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "EUR").withValue("isBaseCurrency", false));
|
||||
assertThat(items().get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
|
||||
assertThat(items().get("Base currency").detail()).startsWith("USD");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The card renders on the home with one linked row per item.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rendersOnTheHome() throws Exception
|
||||
{
|
||||
assertThat(CrmHomeWidgets.HOME).contains("crm_setup");
|
||||
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
|
||||
assertThat(data.getRows()).hasSize(4);
|
||||
CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline");
|
||||
assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The checklist is business setup only: no email-provider row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noEmailProviderRow() throws Exception
|
||||
{
|
||||
assertThat(items().keySet()).noneMatch(label -> label.toLowerCase().contains("email"));
|
||||
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
|
||||
assertThat(data.getRows().toString()).doesNotContain("email qbit").doesNotContain("Email delivery");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Settings holders get the card while setup is incomplete, and not once complete.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsHolderSeesCardOnlyWhileIncomplete() throws Exception
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isTrue();
|
||||
|
||||
completeSetup();
|
||||
assertThat(CrmSetupStatus.isIncomplete()).isFalse();
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
|
||||
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A CRM user without the settings permission never gets the card, incomplete or not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nonSettingsUserNeverSeesCard() throws Exception
|
||||
{
|
||||
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE));
|
||||
assertThat(CrmSetupStatus.isIncomplete()).isTrue();
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
|
||||
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the setup widget the way the UI API does, with permissions enforced.
|
||||
*******************************************************************************/
|
||||
private void renderEnforced() throws Exception
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_setup"));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
new RenderWidgetAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed everything the checklist asks for.
|
||||
*******************************************************************************/
|
||||
private void completeSetup() throws Exception
|
||||
{
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
|
||||
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
|
||||
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
|
||||
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, CrmSetupStatus.Item> items() throws Exception
|
||||
{
|
||||
List<CrmSetupStatus.Item> items = CrmSetupStatus.load();
|
||||
return (items.stream().collect(Collectors.toMap(CrmSetupStatus.Item::label, item -> item, (a, b) -> a, java.util.LinkedHashMap::new)));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user