Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2c2f8a9b8
|
||
|
|
e422092fd4 | ||
|
|
70f841c256 | ||
|
|
730bb68428 | ||
|
|
62c393374d | ||
|
|
c380dce8b6
|
||
|
|
a8e9a34b51 | ||
|
|
26b8847b46
|
||
|
|
820474805d | ||
|
|
5157f81208
|
||
|
|
c6031c26ed | ||
|
|
08e880af2e
|
||
|
|
e3f20d357e | ||
|
|
4710ed38b3
|
||
|
|
0ebbeefa08 | ||
|
|
c5383718a3
|
||
|
|
a025eeee2f | ||
|
|
80bf85a158
|
||
|
|
24707575fc | ||
|
|
d6900e3842
|
||
|
|
dc4ad764f3
|
||
|
|
4b4b33127e
|
||
|
|
c8d765a135 | ||
|
|
2babcaf3b4
|
||
|
|
31964ff0e0
|
||
|
|
50acf63211 | ||
|
|
1f994e127b
|
||
|
|
212c7aaa77
|
||
|
|
40a93c3286
|
||
|
|
da0ba96966
|
||
|
|
b7b146259f
|
||
|
|
7ee43485c3
|
||
|
|
019d93a5d7
|
||
|
|
99102d3175
|
||
|
|
90cdaaa88d | ||
|
|
424f6ea379
|
||
|
|
8d9dfb9172
|
||
|
|
e1fb2c0deb
|
||
|
|
68220ec915
|
||
|
|
666cec834e
|
||
|
|
4b1d272ac5 | ||
|
|
06d41fb3f7
|
||
|
|
a93365a7b7
|
||
|
|
15a2549b59
|
||
|
|
831bc59743 |
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.7.12
|
||||
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.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -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.7.12
|
||||
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,10 @@
|
||||
|
||||
## Unreleased / 0.2.0
|
||||
|
||||
- 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 +14,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).
|
||||
@@ -57,7 +57,14 @@ qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
- `defaultBackendNameForTables`: required backend name.
|
||||
- `inferBackendNames`: defaults to true; sets snake_case SQL column names while Java/QQQ field names remain camelCase. SQL table names equal the QQQ table names. Set false only for an explicit host column mapping.
|
||||
- `tableMetaDataCustomizer`: optional host table customizer.
|
||||
- `securityFields` and `recordSecurityLocks`: retained in P1, applied to contact, company and deal. Host-managed schema columns must match these fields. This is not yet tenant isolation across every child table or workflow.
|
||||
- `withTenantSecurityKeyType("tenant")`: enables nullable `tenantKey` fields and record locks. It defaults to null (off). `withTenantAllAccessKeyName("allTenants")` names the host's all-access key. Existing host key types are preserved.
|
||||
- Tenant-enabled hosts separately include `src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql` **after** the base changelog and explicitly select the `crm-tenancy` context. Single-tenant hosts omit that file. Child records use parent join locks; polymorphic external mappings and internal job checkpoints carry direct tenant keys. Hosts must supply tenant keys on new direct-key records.
|
||||
- `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields.
|
||||
- `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads.
|
||||
- `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
| Scope | Permissions |
|
||||
|---|---|
|
||||
@@ -70,9 +77,19 @@ qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
|
||||
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The app and widget provenance adapters supply core's `SourceQBitAware` interface because RC.6's app and widget classes lack it.
|
||||
|
||||
## Starter defaults
|
||||
|
||||
Include `db/liquibase/qbit-crm/crm-defaults.changelog.sql` in the host changelog after `crm.changelog.sql` and, on a tenant-enabled install, after `crm-tenancy.changelog.sql` as well:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
It seeds, insert-only: the activity types and call/meeting outcomes; the default `Sales Pipeline` (Qualification 10%, Discovery 25%, Proposal 50%, Negotiation 75%, Closed Won 100%, Closed Lost 0%); lifecycle stages (the code looks up `Lead` and `Opportunity` by name); lead sources (`Website` is the web-form default); industries; win and loss reasons (prefixed `Won:` / `Lost:` because names are unique); contact roles; and a USD base currency. Lists are seeded only while empty, so a configured host is unchanged. The lifecycle stages (they change what intake and Convert lead assign) and the base currency are seeded only on a fresh install (no contact, respectively no deal, yet), and the stages only when `Sales Pipeline` is the only pipeline. Rollbacks are empty, so they never delete host rows. Tenant-enabled hosts seed per tenant (the changelog skips tenant-keyed tables). The Setup card on the CRM home shows what is still missing.
|
||||
|
||||
## Tables
|
||||
|
||||
All 33 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata.
|
||||
All 34 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata.
|
||||
|
||||
### Main tables (6)
|
||||
|
||||
@@ -90,35 +107,56 @@ Contact, company, deal and activity declare core FIELD-level audit rules. The be
|
||||
|
||||
## Processes
|
||||
|
||||
The QBit produces 25 processes with these names and labels. QQQ may additionally generate standard table actions such as `<table>.bulkInsert`; those retain core's naming conventions. CRM processes are available to host wiring and are not registered as scheduled jobs.
|
||||
The QBit produces 28 table-bound record actions. Every action uses the standard
|
||||
streamed ETL Review and Result screens with linked summary lines. Required inputs
|
||||
are collected before Review. Processes never appear in app navigation. Find
|
||||
Duplicates scans the selected records (select all matching records to scan the
|
||||
table); each duplicate group links to its members. Merges require exactly two
|
||||
selected records and a choice of which record to keep.
|
||||
|
||||
| Process name | Label |
|
||||
|---|---|
|
||||
| `crm_auto_assign` | Auto Assign |
|
||||
| `crm_bulk_assign_owner` | Bulk Assign Owner |
|
||||
| `crm_calculate_lead_scores` | Calculate Lead Scores |
|
||||
| `crm_clone_deal` | Clone Deal |
|
||||
| `crm_convert_form_to_contact` | Convert Form to Contact |
|
||||
| `crm_convert_lead` | Convert Lead |
|
||||
| `crm_detect_duplicates` | Detect Duplicates |
|
||||
| `crm_enroll_in_sequence` | Enroll in Sequence |
|
||||
| `crm_fire_reminders` | Fire Reminders |
|
||||
| `crm_gdpr_anonymize_contact` | GDPR Anonymize Contact |
|
||||
| `crm_import_contacts` | Import Contacts |
|
||||
| `crm_log_activity` | Log Activity |
|
||||
| `crm_manage_contact_on_deal` | Manage Contact on Deal |
|
||||
| `crm_merge_companies` | Merge Companies |
|
||||
| `crm_merge_contacts` | Merge Contacts |
|
||||
| `crm_move_deal_stage` | Move Deal Stage |
|
||||
| `crm_process_overdue_tasks` | Process Overdue Tasks |
|
||||
| `crm_process_sequence_steps` | Process Sequence Steps |
|
||||
| `crm_reopen_deal` | Reopen Deal |
|
||||
| `crm_send_email` | Send Email |
|
||||
| `crm_subscribe_to_list` | Subscribe to List |
|
||||
| `crm_track_email_event` | Track Email Event |
|
||||
| `crm_transfer_ownership` | Transfer Ownership |
|
||||
| `crm_unenroll_from_sequence` | Unenroll from Sequence |
|
||||
| `crm_unsubscribe_from_list` | Unsubscribe from List |
|
||||
| Table | Process name | Label |
|
||||
|---|---|---|
|
||||
| `crm_contact` | `crm_bulk_assign_owner` | Assign Owner |
|
||||
| `crm_contact` | `crm_auto_assign` | Auto-Assign |
|
||||
| `crm_contact` | `crm_subscribe_to_list` | Add to List |
|
||||
| `crm_contact` | `crm_unsubscribe_from_list` | Remove from List |
|
||||
| `crm_contact` | `crm_enroll_in_sequence` | Enroll in Sequence |
|
||||
| `crm_contact` | `crm_unenroll_from_sequence` | Unenroll from Sequence |
|
||||
| `crm_contact` | `crm_log_activity` | Log Activity |
|
||||
| `crm_contact` | `crm_recalculate_score` | Recalculate Score |
|
||||
| `crm_contact` | `crm_merge_contacts` | Merge Contacts |
|
||||
| `crm_contact` | `crm_detect_duplicates` | Find Duplicates |
|
||||
| `crm_contact` | `crm_convert_lead` | Create Deal |
|
||||
| `crm_contact` | `crm_gdpr_anonymize_contact` | Erase Personal Data |
|
||||
| `crm_contact` | `crm_send_email` | Send Email |
|
||||
| `crm_company` | `crm_assign_company_owner` | Assign Owner |
|
||||
| `crm_company` | `crm_merge_companies` | Merge Companies |
|
||||
| `crm_company` | `crm_find_duplicate_companies` | Find Duplicates |
|
||||
| `crm_company` | `crm_log_company_activity` | Log Activity |
|
||||
| `crm_deal` | `crm_move_deal_stage` | Move Stage |
|
||||
| `crm_deal` | `crm_mark_deal_won` | Mark Won |
|
||||
| `crm_deal` | `crm_mark_deal_lost` | Mark Lost |
|
||||
| `crm_deal` | `crm_reopen_deal` | Reopen |
|
||||
| `crm_deal` | `crm_clone_deal` | Clone |
|
||||
| `crm_deal` | `crm_transfer_ownership` | Assign Owner |
|
||||
| `crm_deal` | `crm_manage_contact_on_deal` | Add Contact |
|
||||
| `crm_deal` | `crm_log_deal_activity` | Log Activity |
|
||||
| `crm_form_submission` | `crm_convert_form_to_contact` | Convert to Contact |
|
||||
| `crm_email_sequence` | `crm_activate_sequence` | Activate |
|
||||
| `crm_email_sequence` | `crm_pause_sequence` | Pause |
|
||||
|
||||
Move Stage and Reopen choose a stage from the selected deals' pipeline. Select
|
||||
one pipeline at a time for those actions. Mark Won and Mark Lost require a
|
||||
win/loss reason and resolve the terminal stage within each deal's pipeline.
|
||||
|
||||
Two hidden background processes have no table binding: `crm_process_sequence_steps`
|
||||
and `crm_calculate_lead_scores`. Both use id keyset pages of 200. They have no
|
||||
shipped schedules yet; the host can invoke them. QQQ also generates standard
|
||||
bulk-load and bulk-edit actions such as `<table>.bulkInsert`.
|
||||
|
||||
Fire Reminders and Process Overdue Tasks were removed in favor of collaboration
|
||||
tasks and alerts. Import Contacts was removed in favor of core bulk load. Track
|
||||
Email Event was removed in favor of the later delivery sync.
|
||||
|
||||
## Current behavior and limits
|
||||
|
||||
@@ -126,18 +164,37 @@ The existing pipeline/stage logic, stage history, product totals, currency conve
|
||||
|
||||
The contact Timeline shows the latest fifty recorded events from activities, email messages, form submissions and consent, newest first by creation date. Native child lists connect all specified parent screens. Deal primary-contact and email-message contact references are included in the fresh-install schema. The trend widgets, redundant recent-deals/activity-count widgets and activity feed have been retired.
|
||||
|
||||
Some process names describe unfinished behavior inherited from 0.1.0:
|
||||
Later phases complete the following behavior:
|
||||
|
||||
- Send Email and sequence email steps record CRM activity/message state; they do not deliver email. Template merging and live delivery tracking are not implemented here.
|
||||
- Sequence execution, lead scoring and overdue-task work require host invocation. No schedules are shipped. Assignment is a process, not an automatic insert hook; reminders do not deliver notifications.
|
||||
- Scoring's date-decay operators remain unimplemented. Import Contacts expects pre-parsed input and does not provide the planned streamed bulk-load workflow.
|
||||
- Send Email is a non-sending action shell. It checks for `CrmEmailTransport` with `Class.forName` and reports **Email sending arrives with the email transport** when absent. It never writes a fake sent message or activity. If the class is present, the shell reports that action integration is still pending. Sequence email steps retain the existing activity-only behavior until transport integration.
|
||||
- Sequence execution and lead scoring require host invocation. No schedules are shipped. Assignment remains an action; automatic insert hooks, collaboration and alert integration arrive later.
|
||||
- Activate/Pause update the sequence active flag. The existing sequence runner's business logic is preserved in P2B and does not yet consult that flag; enforcing it during execution is a follow-up.
|
||||
- Scoring's date-decay operators remain unimplemented. Core bulk load is the import path; saved CRM import profiles arrive later.
|
||||
- GDPR anonymization scrubs the contact, unenrolls sequences and records withdrawn consent. It does not yet erase all linked PII or core audit details. The P1 schema retains existing PII fields; classification, complete erasure and redesigned tenancy are later work.
|
||||
|
||||
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 actions/security/integration work are not part of this phase.
|
||||
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase.
|
||||
|
||||
## In-app help
|
||||
|
||||
CRM registers [help/qbit-crm.yaml](src/main/resources/help/qbit-crm.yaml) from
|
||||
`CrmQBitProducer`. The bundle uses the `QBIT` layer and the current artifact version.
|
||||
Registration only adds metadata and works without a `helpContent` table; QQQ's
|
||||
startup sync skips hosts without that table. Hosts with help storage load help
|
||||
and call `HelpBundles.syncOnStartup(qInstance)` after defining their metadata.
|
||||
|
||||
To customize wording or describe your deployment, register a separate `HOST`
|
||||
bundle with the same entry keys (and roles). HOST text takes precedence over QBIT
|
||||
text; the sync preserves user edits for review. Keep live `{{...}}` references
|
||||
when referring to labels, defaults or enum values.
|
||||
|
||||
`CrmPublicationHelpTest` runs `HelpBundleAssertions` against the produced and
|
||||
validated CRM instance, including generated record actions, and also requires
|
||||
widget and possible-value help. `CrmTablesGuardrailTest` checks every table's
|
||||
row-creation guidance. Run both through `./mvnw -q verify`.
|
||||
|
||||
## Source layout
|
||||
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
|
||||
Licensed under [Apache License 2.0](LICENSE).
|
||||
|
||||
|
||||
+24
@@ -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.
|
||||
+39
-26
@@ -1,11 +1,12 @@
|
||||
-- Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0.
|
||||
-- https://www.apache.org/licenses/LICENSE-2.0
|
||||
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (33 tables).
|
||||
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (34 tables).
|
||||
-- QQQ table names equal SQL names; Java field names map to snake_case columns.
|
||||
-- Unique constraints match QQQ metadata. Relationship columns are indexed;
|
||||
-- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns
|
||||
-- validation and deletion policy, including polymorphic and optional references).
|
||||
-- Optional host-configured securityFields require matching host-managed columns.
|
||||
-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy.
|
||||
-- With tenancy off, do not include it; this base schema has no tenant_key columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_activity (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
@@ -161,27 +162,27 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_record (contact_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
first_touch_utm_source VARCHAR(250),
|
||||
first_touch_utm_medium VARCHAR(250),
|
||||
first_touch_utm_campaign VARCHAR(250),
|
||||
first_touch_utm_content VARCHAR(250),
|
||||
first_touch_utm_term VARCHAR(250),
|
||||
first_touch_utm_source VARCHAR(500),
|
||||
first_touch_utm_medium VARCHAR(500),
|
||||
first_touch_utm_campaign VARCHAR(500),
|
||||
first_touch_utm_content VARCHAR(500),
|
||||
first_touch_utm_term VARCHAR(500),
|
||||
first_touch_landing_page VARCHAR(1000),
|
||||
first_touch_referrer VARCHAR(1000),
|
||||
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_touch_utm_source VARCHAR(250),
|
||||
last_touch_utm_medium VARCHAR(250),
|
||||
last_touch_utm_campaign VARCHAR(250),
|
||||
last_touch_utm_content VARCHAR(250),
|
||||
last_touch_utm_term VARCHAR(250),
|
||||
last_touch_utm_source VARCHAR(500),
|
||||
last_touch_utm_medium VARCHAR(500),
|
||||
last_touch_utm_campaign VARCHAR(500),
|
||||
last_touch_utm_content VARCHAR(500),
|
||||
last_touch_utm_term VARCHAR(500),
|
||||
last_touch_landing_page VARCHAR(1000),
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(250),
|
||||
secondary_email VARCHAR(250),
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
email VARCHAR(320),
|
||||
secondary_email VARCHAR(320),
|
||||
phone VARCHAR(50),
|
||||
mobile_phone VARCHAR(50),
|
||||
job_title VARCHAR(150),
|
||||
@@ -189,7 +190,7 @@ CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
company_id INTEGER,
|
||||
lifecycle_stage_id INTEGER,
|
||||
lead_source_id INTEGER,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
address_line1 VARCHAR(250),
|
||||
address_line2 VARCHAR(250),
|
||||
city VARCHAR(100),
|
||||
@@ -252,7 +253,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
|
||||
weighted_amount DECIMAL(20,6),
|
||||
company_id INTEGER,
|
||||
primary_contact_id INTEGER,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
lead_source_id INTEGER,
|
||||
expected_close_date DATE,
|
||||
actual_close_date DATE,
|
||||
@@ -346,7 +347,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email_list_id INTEGER NOT NULL,
|
||||
contact_id INTEGER,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
status INTEGER DEFAULT 1 NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
@@ -364,9 +365,13 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
idempotency_key VARCHAR(250) UNIQUE,
|
||||
email_message_id VARCHAR(100),
|
||||
delivered_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
bounced_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
activity_id INTEGER,
|
||||
contact_id INTEGER,
|
||||
from_address VARCHAR(250) NOT NULL,
|
||||
from_address VARCHAR(250),
|
||||
from_name VARCHAR(100),
|
||||
to_addresses TEXT NOT NULL,
|
||||
cc_addresses TEXT,
|
||||
@@ -392,6 +397,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_message_id ON crm_email_message (email_message_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
|
||||
@@ -455,18 +461,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
client_hash VARCHAR(64),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
form_type INTEGER NOT NULL,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
company VARCHAR(250),
|
||||
phone VARCHAR(50),
|
||||
message TEXT,
|
||||
source_url VARCHAR(1000),
|
||||
utm_source VARCHAR(250),
|
||||
utm_medium VARCHAR(250),
|
||||
utm_campaign VARCHAR(250),
|
||||
utm_term VARCHAR(250),
|
||||
utm_content VARCHAR(250),
|
||||
utm_source VARCHAR(500),
|
||||
utm_medium VARCHAR(500),
|
||||
utm_campaign VARCHAR(500),
|
||||
utm_term VARCHAR(500),
|
||||
utm_content VARCHAR(500),
|
||||
referrer VARCHAR(1000),
|
||||
contact_id INTEGER,
|
||||
conversion_status INTEGER DEFAULT 1 NOT NULL,
|
||||
@@ -640,3 +646,10 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_job_state (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL UNIQUE,
|
||||
last_success TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_full_pass TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
@@ -73,6 +73,17 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<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>
|
||||
@@ -102,6 +113,13 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
<version>4.33.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -117,7 +135,35 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-crm.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-crm.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -186,14 +232,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.7</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -30,17 +30,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CRM home and the two navigation sections. Child tables stay on records.
|
||||
*******************************************************************************/
|
||||
public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAware
|
||||
public final class CrmAppMetaData
|
||||
{
|
||||
private String sourceQBitName;
|
||||
|
||||
public static final List<String> MAIN_TABLES = List.of("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list");
|
||||
public static final List<String> SETTINGS_TABLES = List.of("crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
|
||||
"crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_currency", "crm_lifecycle_stage", "crm_lead_source",
|
||||
@@ -70,7 +67,7 @@ public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAwar
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData build(MetaDataProducerMultiOutput output, String source)
|
||||
{
|
||||
QAppMetaData app = new CrmAppMetaData().withSourceQBitName(source).withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
|
||||
QAppMetaData app = new QAppMetaData().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
|
||||
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
|
||||
.withWidgets(new ArrayList<>(CrmHomeWidgets.HOME))
|
||||
.withSection(mainSection())
|
||||
@@ -84,34 +81,4 @@ public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAwar
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit provenance, until core apps implement SourceQBitAware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getSourceQBitName()
|
||||
{
|
||||
return (sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit provenance, until core apps implement SourceQBitAware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit provenance, until core apps implement SourceQBitAware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public CrmAppMetaData withSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
setSourceQBitName(sourceQBitName);
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
|
||||
import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData;
|
||||
import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host API publication and the core record-search contract.
|
||||
*******************************************************************************/
|
||||
public final class CrmIntegrations
|
||||
{
|
||||
private static final Map<String, List<String>> SEARCH_FIELDS = Map.of(
|
||||
"crm_contact", List.of("firstName", "lastName", "email"),
|
||||
"crm_company", List.of("name", "domain"), "crm_deal", List.of("name"),
|
||||
"crm_activity", List.of("subject"), "crm_form_submission", List.of("email"), "crm_email_list", List.of("name"));
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enable publication only when the host supplies both API settings.
|
||||
*******************************************************************************/
|
||||
public static void apply(MetaDataProducerMultiOutput output, CrmQBitConfig config) throws QException
|
||||
{
|
||||
if(config.getEsbDestinationName() != null)
|
||||
{
|
||||
throw (new QException("CRM ESB publication requires classified-value exclusion, which QQQ 4.1.0-RC.6 does not provide."));
|
||||
}
|
||||
boolean api = config.getApiName() != null && config.getApiVersion() != null;
|
||||
if(api)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer");
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
throw (new QException("CRM API publication requires qqq-middleware-api on the host classpath.", e));
|
||||
}
|
||||
}
|
||||
for(QTableMetaData table : output.getEach(QTableMetaData.class))
|
||||
{
|
||||
if(SEARCH_FIELDS.containsKey(table.getName()))
|
||||
{
|
||||
table.withSearchFields(SEARCH_FIELDS.get(table.getName()));
|
||||
}
|
||||
if(api && CrmDataPolicy.isPublishedTable(table.getName()))
|
||||
{
|
||||
ApiTableMetaDataContainer.ofOrWithNew(table).withApiTableMetaData(config.getApiName(),
|
||||
new ApiTableMetaData().withInitialVersion(config.getApiVersion()).withIsExcluded(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM presentation shared by package-discovered tables and child widgets.
|
||||
*******************************************************************************/
|
||||
public final class CrmPresentation
|
||||
{
|
||||
private static final Map<String, String> LABELS = Map.ofEntries(
|
||||
Map.entry("crm_contact", "Contacts"),
|
||||
Map.entry("crm_company", "Companies"),
|
||||
Map.entry("crm_deal", "Deals"),
|
||||
Map.entry("crm_activity", "Activities"),
|
||||
Map.entry("crm_form_submission", "Web Leads"),
|
||||
Map.entry("crm_email_list", "Lists"),
|
||||
Map.entry("crm_pipeline", "Pipelines"),
|
||||
Map.entry("crm_pipeline_stage", "Pipeline Stages"),
|
||||
Map.entry("crm_product", "Products"),
|
||||
Map.entry("crm_email_template", "Email Templates"),
|
||||
Map.entry("crm_email_sequence", "Email Sequences"),
|
||||
Map.entry("crm_sequence_step", "Sequence Steps"),
|
||||
Map.entry("crm_sequence_enrollment", "Sequence Enrollments"),
|
||||
Map.entry("crm_lead_score_rule", "Lead Score Rules"),
|
||||
Map.entry("crm_assignment_rule", "Assignment Rules"),
|
||||
Map.entry("crm_assignment_rule_member", "Assignment Rule Members"),
|
||||
Map.entry("crm_sales_goal", "Sales Goals"),
|
||||
Map.entry("crm_currency", "Currencies"),
|
||||
Map.entry("crm_lifecycle_stage", "Lifecycle Stages"),
|
||||
Map.entry("crm_lead_source", "Lead Sources"),
|
||||
Map.entry("crm_industry", "Industries"),
|
||||
Map.entry("crm_activity_type", "Activity Types"),
|
||||
Map.entry("crm_activity_outcome", "Activity Outcomes"),
|
||||
Map.entry("crm_contact_role", "Contact Roles"),
|
||||
Map.entry("crm_win_loss_reason", "Win/Loss Reasons"),
|
||||
Map.entry("crm_consent_record", "Consent Records"),
|
||||
Map.entry("crm_email_list_member", "List Members"),
|
||||
Map.entry("crm_email_message", "Email Messages"),
|
||||
Map.entry("crm_deal_contact", "Deal Contacts"),
|
||||
Map.entry("crm_deal_product", "Deal Products"),
|
||||
Map.entry("crm_deal_stage_history", "Deal Stage History"),
|
||||
Map.entry("crm_activity_participant", "Activity Participants"),
|
||||
Map.entry("crm_external_mapping", "External Mappings"),
|
||||
Map.entry("crm_job_state", "Job State"));
|
||||
private static final Map<String, List<String>> CHILD_FIELDS = Map.ofEntries(
|
||||
Map.entry("crm_contact", List.of("firstName", "lastName", "email", "phone")),
|
||||
Map.entry("crm_deal", List.of("name", "pipelineStageId", "amount", "expectedCloseDate")),
|
||||
Map.entry("crm_activity", List.of("subject", "activityTypeId", "startDate")),
|
||||
Map.entry("crm_form_submission", List.of("formKey", "createDate", "conversionStatus")),
|
||||
Map.entry("crm_email_list_member", List.of("emailListId", "status")),
|
||||
Map.entry("crm_consent_record", List.of("consentType", "status", "consentDate")),
|
||||
Map.entry("crm_sequence_enrollment", List.of("sequenceId", "status", "enrolledDate")),
|
||||
Map.entry("crm_email_message", List.of("subject", "status", "sentDate")),
|
||||
Map.entry("crm_deal_contact", List.of("contactId", "contactRoleId", "isPrimary")),
|
||||
Map.entry("crm_deal_product", List.of("productId", "quantity", "unitPrice", "totalAmount")),
|
||||
Map.entry("crm_deal_stage_history", List.of("fromStageId", "toStageId", "transitionDate", "userId")));
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmPresentation()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit labels avoid exposing technical table and user-id names.
|
||||
*******************************************************************************/
|
||||
public static void configureTable(QTableMetaData table)
|
||||
{
|
||||
table.setLabel(LABELS.get(table.getName()));
|
||||
Map<String, String> users = Map.of("ownerUserId", "Owner", "assignedToUserId", "Assigned To",
|
||||
"assignToUserId", "Assign To", "enrolledByUserId", "Enrolled By", "convertedByUserId", "Converted By", "userId", "User");
|
||||
table.getFields().values().forEach(field ->
|
||||
{
|
||||
if(users.containsKey(field.getName()))
|
||||
{
|
||||
field.setLabel(users.get(field.getName()));
|
||||
}
|
||||
});
|
||||
Map<String, List<String>> money = Map.of("crm_deal", List.of("amount", "amountInBaseCurrency", "weightedAmount"),
|
||||
"crm_deal_product", List.of("unitPrice", "totalAmount"), "crm_product", List.of("unitPrice"), "crm_sales_goal", List.of("targetAmount"));
|
||||
for(String field : money.getOrDefault(table.getName(), List.of()))
|
||||
{
|
||||
table.getField(field).setDisplayFormat(DisplayFormat.CURRENCY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 supports a per-widget field list, preserving each full child table.
|
||||
*******************************************************************************/
|
||||
public static void configureChildList(QWidgetMetaData widget, String childTable)
|
||||
{
|
||||
List<String> fields = CHILD_FIELDS.get(childTable);
|
||||
if(fields != null)
|
||||
{
|
||||
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,8 @@ package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
@@ -43,6 +41,15 @@ public class CrmQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
|
||||
|
||||
private String tenantSecurityKeyType;
|
||||
private String tenantAllAccessKeyName;
|
||||
private String apiName;
|
||||
private String apiVersion;
|
||||
private String esbDestinationName;
|
||||
|
||||
private String schedulerName;
|
||||
private Boolean schedulesEnabled = true;
|
||||
|
||||
private String messagingProviderName;
|
||||
|
||||
private String publicBaseUrl;
|
||||
@@ -53,8 +60,6 @@ public class CrmQBitConfig implements QBitConfig
|
||||
private Boolean inferBackendNames = true;
|
||||
private Boolean appEnabled = true;
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
private List<QFieldMetaData> securityFields;
|
||||
private List<RecordSecurityLock> recordSecurityLocks;
|
||||
|
||||
|
||||
|
||||
@@ -134,48 +139,6 @@ public class CrmQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for securityFields
|
||||
*******************************************************************************/
|
||||
public List<QFieldMetaData> getSecurityFields()
|
||||
{
|
||||
return (this.securityFields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for securityFields
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
|
||||
{
|
||||
this.securityFields = securityFields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordSecurityLocks
|
||||
*******************************************************************************/
|
||||
public List<RecordSecurityLock> getRecordSecurityLocks()
|
||||
{
|
||||
return (this.recordSecurityLocks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordSecurityLocks
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
|
||||
{
|
||||
this.recordSecurityLocks = recordSecurityLocks;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether to infer snake_case database column names.
|
||||
*******************************************************************************/
|
||||
@@ -271,4 +234,133 @@ public class CrmQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduler configuration: schedulerName.
|
||||
*******************************************************************************/
|
||||
public String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure schedulerName.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduler configuration: schedulesEnabled.
|
||||
*******************************************************************************/
|
||||
public Boolean getSchedulesEnabled()
|
||||
{
|
||||
return (schedulesEnabled);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure schedulesEnabled.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withSchedulesEnabled(Boolean schedulesEnabled)
|
||||
{
|
||||
this.schedulesEnabled = schedulesEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tenantSecurityKeyType.
|
||||
*******************************************************************************/
|
||||
public String getTenantSecurityKeyType()
|
||||
{
|
||||
return (tenantSecurityKeyType);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure tenantSecurityKeyType; null disables the optional integration.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withTenantSecurityKeyType(String tenantSecurityKeyType)
|
||||
{
|
||||
this.tenantSecurityKeyType = tenantSecurityKeyType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tenantAllAccessKeyName.
|
||||
*******************************************************************************/
|
||||
public String getTenantAllAccessKeyName()
|
||||
{
|
||||
return (tenantAllAccessKeyName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure tenantAllAccessKeyName; null disables the optional integration.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withTenantAllAccessKeyName(String tenantAllAccessKeyName)
|
||||
{
|
||||
this.tenantAllAccessKeyName = tenantAllAccessKeyName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for apiName.
|
||||
*******************************************************************************/
|
||||
public String getApiName()
|
||||
{
|
||||
return (apiName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure apiName; null disables the optional integration.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withApiName(String apiName)
|
||||
{
|
||||
this.apiName = apiName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for apiVersion.
|
||||
*******************************************************************************/
|
||||
public String getApiVersion()
|
||||
{
|
||||
return (apiVersion);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure apiVersion; null disables the optional integration.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withApiVersion(String apiVersion)
|
||||
{
|
||||
this.apiVersion = apiVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for esbDestinationName.
|
||||
*******************************************************************************/
|
||||
public String getEsbDestinationName()
|
||||
{
|
||||
return (esbDestinationName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure esbDestinationName; null disables the optional integration.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withEsbDestinationName(String esbDestinationName)
|
||||
{
|
||||
this.esbDestinationName = esbDestinationName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -26,20 +26,30 @@
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.customizers.CurrencyRateDateCustomizer;
|
||||
import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.Currency;
|
||||
import com.kingsrook.qbits.crm.core.model.FormSubmission;
|
||||
import com.kingsrook.qbits.crm.deals.customizers.DealInitializationCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
|
||||
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qbits.crm.security.CrmRelationshipValidator;
|
||||
import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
@@ -54,13 +64,18 @@ import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
@@ -74,12 +89,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.
|
||||
*******************************************************************************/
|
||||
@@ -106,18 +149,21 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
{
|
||||
join.setName(snakeCase(join.getName()));
|
||||
}
|
||||
CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig);
|
||||
CrmIntegrations.apply(metaDataProducerMultiOutput, 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()));
|
||||
@@ -129,8 +175,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"));
|
||||
@@ -139,6 +183,12 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
{
|
||||
process.setPermissionRules(CrmPermissions.processRules(process.getName()));
|
||||
process.setSourceQBitName(getQBitMetaData().getName());
|
||||
Integer seconds = Map.of("crm_process_sequence_steps", 300, "crm_calculate_lead_scores", 3600,
|
||||
"crm_sync_email_delivery", 900, "crm_expire_consents", 86400).get(process.getName());
|
||||
if(seconds != null && qBitConfig.getSchedulerName() != null && !Boolean.FALSE.equals(qBitConfig.getSchedulesEnabled()))
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(qBitConfig.getSchedulerName()).withRepeatSeconds(seconds));
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
@@ -147,6 +197,16 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
|
||||
{
|
||||
String tableName = table.getName();
|
||||
CrmPresentation.configureTable(table);
|
||||
if(tableName.equals(Contact.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class));
|
||||
}
|
||||
if(tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CrmRelationshipValidator.class));
|
||||
addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CrmRelationshipValidator.class));
|
||||
}
|
||||
table.setPermissionRules(CrmPermissions.tableRules(tableName));
|
||||
table.setSourceQBitName(getQBitMetaData().getName());
|
||||
if(table.getSections() != null)
|
||||
@@ -171,11 +231,26 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
|| tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
|
||||
{
|
||||
table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
|
||||
RecordFeaturesMetaData features = RecordFeaturesMetaData.ofOrWithNew(table)
|
||||
.withOwnership(new RecordOwnership().withOwnerFieldName("ownerUserId"));
|
||||
for(RecordFeature feature : List.of(RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.ATTACHMENTS,
|
||||
RecordFeature.FOLLOW, RecordFeature.TIMELINE, RecordFeature.RECENTS, RecordFeature.FAVORITES))
|
||||
{
|
||||
features.withEnabled(feature, true);
|
||||
}
|
||||
if(!tableName.equals(Activity.TABLE_NAME))
|
||||
{
|
||||
features.withEnabled(RecordFeature.TASKS, true).withEnabled(RecordFeature.ASSIGNMENT, true);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// wire activity timestamp propagation //
|
||||
///////////////////////////////////////////////////////////////
|
||||
if(tableName.equals(Contact.TABLE_NAME) || tableName.equals(Deal.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(AutoAssignCustomizer.class));
|
||||
}
|
||||
if(tableName.equals(Activity.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(ActivityLastDateCustomizer.class));
|
||||
@@ -190,6 +265,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 //
|
||||
///////////////////////////////////////////////////////////////
|
||||
@@ -324,7 +408,15 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
.withSourceQBitName(getQBitMetaData().getName()));
|
||||
output.add(new QAlertTypeMetaData().withName("crm.email.unavailable").withLabel("Email unavailable")
|
||||
.withSourceQBitName(getQBitMetaData().getName()));
|
||||
for(String notification : List.of("crm.lead.assigned", "crm.deal.won", "crm.deal.rotting", "crm.task.due"))
|
||||
{
|
||||
output.add(new QAlertTypeMetaData().withName(notification).withLabel(notification)
|
||||
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
|
||||
}
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
|
||||
registerHooks(qInstance);
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.MergeContactsProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess;
|
||||
import com.kingsrook.qbits.crm.deals.processes.CloneDealProcess;
|
||||
import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess;
|
||||
import com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmails;
|
||||
import com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared streamed action lifecycle. Review never invokes a mutating operation.
|
||||
*******************************************************************************/
|
||||
public final class CrmAction
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CrmAction.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility holder.
|
||||
*******************************************************************************/
|
||||
private CrmAction()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin extraction to metadata and require an explicit record selection/filter.
|
||||
*******************************************************************************/
|
||||
public static class Extract extends ExtractViaQueryStep
|
||||
{
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String table = CrmActions.action(input.getProcessName()).table();
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
super.preRun(input, output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse the core selection parser for preparation and merge validation.
|
||||
*******************************************************************************/
|
||||
QQueryFilter selection(RunBackendStepInput input) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(input.getValueString("queryFilterJson"))
|
||||
&& (input.getCallback() == null || input.getCallback().getQueryFilter() == null))
|
||||
{
|
||||
throw (new QUserFacingException("Select records before running this action"));
|
||||
}
|
||||
return (super.getQueryFilter(input).clone());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (selection(input));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate read-only pipeline context and the merge survivor dropdown.
|
||||
*******************************************************************************/
|
||||
public static class Prepare implements BackendStep
|
||||
{
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
QQueryFilter filter = new Extract().selection(input);
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
List<QRecord> rows = selectedPair(action, input);
|
||||
output.addValue("mergeIds", new ArrayList<>(rows.stream().map(r -> r.getValue("id")).toList()));
|
||||
output.addValue("primaryId", rows.getFirst().getValue("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter.withLimit(2))
|
||||
.withFieldNamesToInclude(Set.of("pipelineId")).withSelectDistinct(true)).getRecords();
|
||||
List<Serializable> pipelines = rows.stream().map(r -> r.getValue("pipelineId")).distinct().toList();
|
||||
if(pipelines.size() != 1)
|
||||
{
|
||||
throw (new QUserFacingException("Select deals from one pipeline to choose a target stage"));
|
||||
}
|
||||
output.addValue("pipelineId", pipelines.getFirst());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and describe each selected row, preserving keys for standard links.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve the host transport; tests may supply a fake.
|
||||
*******************************************************************************/
|
||||
protected CrmEmailTransport emailTransport() throws QException
|
||||
{
|
||||
return (new CrmEmailTransport(CrmApi.config()));
|
||||
}
|
||||
|
||||
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
|
||||
private final Map<String, List<Serializable>> duplicateGroups = new LinkedHashMap<>();
|
||||
private List<QRecord> pair;
|
||||
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
pair = selectedPair(action, input);
|
||||
if(pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), input.getValue("primaryId"))))
|
||||
{
|
||||
throw (new QUserFacingException("The record to keep must be one of the two selected records"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
for(QRecord extracted : input.getRecords())
|
||||
{
|
||||
Serializable id = extracted.getValue("id");
|
||||
try
|
||||
{
|
||||
QRecord row = requireRow(action, input, id);
|
||||
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
|
||||
if(reason != null)
|
||||
{
|
||||
add(lines, Status.WARNING, reason, reason, id);
|
||||
continue;
|
||||
}
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
if(!Objects.equals(id, input.getValue("primaryId")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Serializable secondary = pair.stream().map(r -> r.getValue("id")).filter(key -> !key.equals(id)).findFirst().orElseThrow();
|
||||
requireRow(action, input, secondary);
|
||||
row.setValue("mergeSecondaryId", secondary);
|
||||
}
|
||||
if(action.operation().equals("duplicates"))
|
||||
{
|
||||
String key = row.getValueString(action.table().equals("crm_contact") ? "email" : "domain");
|
||||
if(StringUtils.hasContent(key))
|
||||
{
|
||||
duplicateGroups.computeIfAbsent(key.toLowerCase(Locale.ROOT), ignored -> new ArrayList<>()).add(id);
|
||||
}
|
||||
}
|
||||
String future = action.operation().equals("auto") ? "will be evaluated for assignment" : "will be " + action.past();
|
||||
add(lines, Status.OK, future, "was " + action.past(), id);
|
||||
output.addRecord(row);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
add(lines, Status.WARNING, e.getMessage(), e.getMessage(), id);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("CRM action validation failed", e);
|
||||
add(lines, Status.ERROR, "cannot be processed; validation failed (see server log)", "was not processed; validation failed (see server log)", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> summary = new ArrayList<>(lines.values());
|
||||
int group = 0;
|
||||
for(List<Serializable> ids : duplicateGroups.values())
|
||||
{
|
||||
if(ids.size() > 1)
|
||||
{
|
||||
ProcessSummaryLine line = line(Status.WARNING, "will appear in duplicate group " + (++group), "appeared in duplicate group " + group);
|
||||
ids.forEach(line::incrementCountAndAddPrimaryKey);
|
||||
summary.add(line);
|
||||
}
|
||||
}
|
||||
return (summary);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reauthorize and validate at execution time; count only actual outcomes.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve the host transport; tests may supply a fake.
|
||||
*******************************************************************************/
|
||||
protected CrmEmailTransport emailTransport() throws QException
|
||||
{
|
||||
return (new CrmEmailTransport(CrmApi.config()));
|
||||
}
|
||||
|
||||
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
|
||||
private boolean duplicates;
|
||||
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
duplicates = action.operation().equals("duplicates");
|
||||
for(QRecord reviewed : input.getRecords())
|
||||
{
|
||||
Serializable id = reviewed.getValue("id");
|
||||
try
|
||||
{
|
||||
QRecord row = requireRow(action, input, id);
|
||||
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
|
||||
if(reason != null)
|
||||
{
|
||||
add(lines, Status.WARNING, reason, reason, id);
|
||||
continue;
|
||||
}
|
||||
row.setValue("mergeSecondaryId", reviewed.getValue("mergeSecondaryId"));
|
||||
String skipped = perform(action, input, row, action.operation().equals("email") ? emailTransport() : null);
|
||||
if(skipped == null)
|
||||
{
|
||||
add(lines, Status.OK, "will be " + action.past(), "was " + action.past(), id);
|
||||
}
|
||||
else
|
||||
{
|
||||
add(lines, Status.WARNING, skipped, skipped, id);
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("CRM action execution failed", e);
|
||||
String reason = e instanceof QUserFacingException ? e.getMessage() : "see server log";
|
||||
add(lines, Status.ERROR, "cannot be processed: " + reason, "failed: " + reason, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> summary = new ArrayList<>(lines.values());
|
||||
if(getTransformStep() instanceof Transform transform)
|
||||
{
|
||||
if(duplicates)
|
||||
{
|
||||
return (transform.getProcessSummary(output, result));
|
||||
}
|
||||
transform.lines.values().stream().filter(line -> line.getStatus() != Status.OK).forEach(summary::add);
|
||||
}
|
||||
return (summary);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read current records under the session; read access alone cannot mutate.
|
||||
*******************************************************************************/
|
||||
private static QRecord requireRow(CrmActions.Action action, RunBackendStepInput input, Serializable id) throws QException
|
||||
{
|
||||
TablePermissionSubType permission = action.operation().equals("duplicates") ? TablePermissionSubType.READ : TablePermissionSubType.EDIT;
|
||||
if(!PermissionsHelper.hasProcessPermission(input, action.name()) || !PermissionsHelper.hasTablePermission(input, action.table(), permission))
|
||||
{
|
||||
throw (new QUserFacingException("Permission denied for this action"));
|
||||
}
|
||||
QRecord row = GetAction.execute(action.table(), id);
|
||||
if(row == null)
|
||||
{
|
||||
throw (new QUserFacingException("Record is unavailable"));
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Merge only an explicit pair, including when called outside the UI.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> selectedPair(CrmActions.Action action, RunBackendStepInput input) throws QException
|
||||
{
|
||||
QQueryFilter filter = new Extract().selection(input);
|
||||
filter.setLimit(3);
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter)).getRecords();
|
||||
if(rows.size() != 2)
|
||||
{
|
||||
throw (new QUserFacingException("Select exactly two records to merge"));
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared read-only checks for Review and Result.
|
||||
*******************************************************************************/
|
||||
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
for(QFieldMetaData field : CrmActions.inputFields(action))
|
||||
{
|
||||
Serializable value = input.getValue(field.getName());
|
||||
if(Boolean.TRUE.equals(field.getIsRequired()) && (value == null || value instanceof String text && text.isBlank()))
|
||||
{
|
||||
return ("Supply " + field.getLabel() + " before reviewing");
|
||||
}
|
||||
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
|
||||
{
|
||||
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
|
||||
{
|
||||
return (field.getLabel() + " is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
switch(action.operation())
|
||||
{
|
||||
case "email":
|
||||
return (CrmEmails.skipReason(row, transport));
|
||||
case "subscribe", "unsubscribe":
|
||||
if(!StringUtils.hasContent(row.getValueString("email")))
|
||||
{
|
||||
return ("Contact has no email address");
|
||||
}
|
||||
break;
|
||||
case "enroll":
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("doNotEmail")))
|
||||
{
|
||||
return ("Contact has opted out of email");
|
||||
}
|
||||
if(!Boolean.TRUE.equals(GetAction.execute("crm_email_sequence", input.getValue("sequenceId")).getValueBoolean("isActive")))
|
||||
{
|
||||
return ("Sequence is not active");
|
||||
}
|
||||
if(!enrollments(input, row).isEmpty())
|
||||
{
|
||||
return ("Contact is already enrolled in this sequence");
|
||||
}
|
||||
break;
|
||||
case "unenroll":
|
||||
if(enrollments(input, row).isEmpty())
|
||||
{
|
||||
return ("Contact has no active enrollment in this sequence");
|
||||
}
|
||||
break;
|
||||
case "stage", "won", "lost", "reopen":
|
||||
QRecord target = targetStage(action, input, row);
|
||||
if(target == null)
|
||||
{
|
||||
return ("No matching target stage exists in the deal's pipeline");
|
||||
}
|
||||
if(!Objects.equals(row.getValue("pipelineId"), target.getValue("pipelineId")))
|
||||
{
|
||||
return ("Target stage does not belong to this deal's pipeline");
|
||||
}
|
||||
if(Objects.equals(row.getValue("pipelineStageId"), target.getValue("id")))
|
||||
{
|
||||
return ("Deal is already in the target stage");
|
||||
}
|
||||
if(action.operation().equals("reopen"))
|
||||
{
|
||||
QRecord current = GetAction.execute("crm_pipeline_stage", row.getValue("pipelineStageId"));
|
||||
if(current == null || !closed(current) || closed(target))
|
||||
{
|
||||
return ("Reopen requires a closed deal and an open target stage");
|
||||
}
|
||||
}
|
||||
if(StringUtils.hasContent(target.getValueString("requiredFieldsJson")))
|
||||
{
|
||||
try
|
||||
{
|
||||
for(Object field : JsonUtils.toObject(target.getValueString("requiredFieldsJson"), List.class))
|
||||
{
|
||||
if(row.getValue(field.toString()) == null)
|
||||
{
|
||||
return ("Required field '" + field + "' must be set before moving to this stage");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw (new QException("Invalid stage required fields", e));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "createDeal":
|
||||
if(query("crm_pipeline_stage", "pipelineId", input.getValue("pipelineId")).isEmpty())
|
||||
{
|
||||
return ("The pipeline has no stages");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal stage lookup stays within each selected deal's pipeline.
|
||||
*******************************************************************************/
|
||||
private static QRecord targetStage(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
if(List.of("stage", "reopen").contains(action.operation()))
|
||||
{
|
||||
return (GetAction.execute("crm_pipeline_stage", input.getValue("targetStageId")));
|
||||
}
|
||||
List<QRecord> stages = new QueryAction().execute(new QueryInput("crm_pipeline_stage").withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, row.getValue("pipelineId")))
|
||||
.withCriteria(new QFilterCriteria(action.operation().equals("won") ? "isClosedWon" : "isClosedLost", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")).withLimit(1))).getRecords();
|
||||
return (stages.isEmpty() ? null : stages.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Closed-stage predicate.
|
||||
*******************************************************************************/
|
||||
private static boolean closed(QRecord stage)
|
||||
{
|
||||
return (Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing business operations only at Load time.
|
||||
*******************************************************************************/
|
||||
private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
RunBackendStepInput call = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(new HashMap<>());
|
||||
for(QFieldMetaData field : CrmActions.inputFields(action))
|
||||
{
|
||||
call.addValue(field.getName(), input.getValue(field.getName()));
|
||||
}
|
||||
Serializable id = row.getValue("id");
|
||||
call.addValue("contactId", action.table().equals("crm_contact") ? id : input.getValue("contactId"));
|
||||
call.addValue("companyId", action.table().equals("crm_company") ? id : null);
|
||||
call.addValue("dealId", action.table().equals("crm_deal") ? id : null);
|
||||
call.addValue("formSubmissionId", id);
|
||||
RunBackendStepOutput result = new RunBackendStepOutput();
|
||||
switch(action.operation())
|
||||
{
|
||||
case "email" ->
|
||||
{
|
||||
String processUUID = input.getValueString("crmEmailProcessUUID");
|
||||
if(processUUID == null)
|
||||
{
|
||||
throw (new QUserFacingException("Email process identity is missing; start the Send Email action again"));
|
||||
}
|
||||
CrmEmails.send(transport, row, input.getValueInteger("emailTemplateId"), input.getValueString("subject"), input.getValueString("body"),
|
||||
"email", id + ":" + processUUID, null);
|
||||
}
|
||||
case "assign" -> update(action.table(), new QRecord().withValue("id", id).withValue("ownerUserId", input.getValue("newOwnerUserId")));
|
||||
case "auto" ->
|
||||
{
|
||||
call.addValue("entityType", "CONTACT");
|
||||
call.addValue("recordId", id);
|
||||
new AutoAssignProcess().run(call, result);
|
||||
if(!"ASSIGNED".equals(result.getValue("action")))
|
||||
{
|
||||
return ("No assignment rule matched; owner unchanged");
|
||||
}
|
||||
}
|
||||
case "subscribe", "unsubscribe" ->
|
||||
{
|
||||
call.addValue("email", row.getValue("email"));
|
||||
call.addValue("firstName", row.getValue("firstName"));
|
||||
call.addValue("subscriptionSource", "CRM record action");
|
||||
if(action.operation().equals("subscribe"))
|
||||
{
|
||||
new SubscribeToListProcess().run(call, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
new UnsubscribeFromListProcess().run(call, result);
|
||||
}
|
||||
if(List.of("NOOP", "NOT_FOUND").contains(result.getValue("action")))
|
||||
{
|
||||
return ("List membership unchanged");
|
||||
}
|
||||
}
|
||||
case "enroll" -> new EnrollInSequenceProcess().run(call, result);
|
||||
case "unenroll" ->
|
||||
{
|
||||
for(QRecord enrollment : enrollments(input, row))
|
||||
{
|
||||
call.addValue("enrollmentId", enrollment.getValue("id"));
|
||||
new UnenrollFromSequenceProcess().run(call, result);
|
||||
}
|
||||
}
|
||||
case "activity" -> new LogActivityProcess().run(call, result);
|
||||
case "score" -> new CalculateLeadScoresProcess().recalculate(row);
|
||||
case "merge" ->
|
||||
{
|
||||
Serializable secondary = row.getValue("mergeSecondaryId");
|
||||
List<QRecord> pair = selectedPair(action, input);
|
||||
if(secondary == null || secondary.equals(id) || pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), secondary)))
|
||||
{
|
||||
throw (new QUserFacingException("Merge selection changed; review it again"));
|
||||
}
|
||||
requireRow(action, input, secondary);
|
||||
String entity = action.table().equals("crm_contact") ? "Contact" : "Company";
|
||||
call.addValue("primary" + entity + "Id", id);
|
||||
call.addValue("secondary" + entity + "Id", secondary);
|
||||
if(entity.equals("Contact"))
|
||||
{
|
||||
new MergeContactsProcess().run(call, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
new MergeCompaniesProcess().run(call, result);
|
||||
}
|
||||
}
|
||||
case "duplicates" ->
|
||||
{
|
||||
}
|
||||
case "createDeal" -> new ConvertLeadProcess().run(call, result);
|
||||
case "erase" -> new GdprAnonymizeContactProcess().run(call, result);
|
||||
case "stage", "won", "lost", "reopen" ->
|
||||
{
|
||||
QRecord change = new QRecord().withValue("id", id).withValue("pipelineStageId", targetStage(action, input, row).getValue("id"));
|
||||
if(List.of("won", "lost").contains(action.operation()))
|
||||
{
|
||||
change.setValue("winLossReasonId", input.getValue("winLossReasonId"));
|
||||
}
|
||||
update(action.table(), change);
|
||||
}
|
||||
case "clone" -> new CloneDealProcess().run(call, result);
|
||||
case "addContact" ->
|
||||
{
|
||||
call.addValue("action", "ADD");
|
||||
new ManageContactOnDealProcess().run(call, result);
|
||||
}
|
||||
case "convert" -> new ConvertFormToContactProcess().run(call, result);
|
||||
case "activate", "pause" -> update(action.table(), new QRecord().withValue("id", id).withValue("isActive", action.operation().equals("activate")));
|
||||
default -> throw (new QUserFacingException("Action is not available"));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core DML can return record errors; never present those as successful writes.
|
||||
*******************************************************************************/
|
||||
private static void update(String table, QRecord change) throws QException
|
||||
{
|
||||
List<QRecord> records = new UpdateAction().execute(new UpdateInput(table).withRecord(change)).getRecords();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("Update was rejected: " + record.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active enrollments belonging to the selected contact and chosen sequence.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> enrollments(RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, row.getValue("id")))
|
||||
.withCriteria(new QFilterCriteria("sequenceId", QCriteriaOperator.EQUALS, input.getValue("sequenceId")))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Session-scoped related rows.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, String field, Serializable value) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)))).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard future and past messages work on both Review and Result.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
return (new ProcessSummaryLine(status).withSingularFutureMessage(future).withPluralFutureMessage(future)
|
||||
.withSingularPastMessage(past).withPluralPastMessage(past.startsWith("was ") ? "were " + past.substring(4) : past));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Group equal outcomes and retain primary keys for standard record links.
|
||||
*******************************************************************************/
|
||||
private static void add(Map<String, ProcessSummaryLine> lines, Status status, String future, String past, Serializable id)
|
||||
{
|
||||
lines.computeIfAbsent(status + ":" + future, ignored -> line(status, future, past)).incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM record actions. The installed definition owns the table and operation.
|
||||
*******************************************************************************/
|
||||
public class CrmActions implements MetaDataProducerInterface<MetaDataProducerMultiOutput>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Immutable action definition; no session state is shared between runs.
|
||||
*******************************************************************************/
|
||||
public record Action(String name, String table, String label, String operation, String past)
|
||||
{
|
||||
}
|
||||
|
||||
public static final List<Action> ACTIONS = List.of(
|
||||
new Action("crm_bulk_assign_owner", "crm_contact", "Assign Owner", "assign", "assigned a new owner"),
|
||||
new Action("crm_auto_assign", "crm_contact", "Auto-Assign", "auto", "auto-assigned"),
|
||||
new Action("crm_subscribe_to_list", "crm_contact", "Add to List", "subscribe", "added to the list"),
|
||||
new Action("crm_unsubscribe_from_list", "crm_contact", "Remove from List", "unsubscribe", "removed from the list"),
|
||||
new Action("crm_enroll_in_sequence", "crm_contact", "Enroll in Sequence", "enroll", "enrolled in the sequence"),
|
||||
new Action("crm_unenroll_from_sequence", "crm_contact", "Unenroll from Sequence", "unenroll", "unenrolled from the sequence"),
|
||||
new Action("crm_log_activity", "crm_contact", "Log Activity", "activity", "given a logged activity"),
|
||||
new Action("crm_recalculate_score", "crm_contact", "Recalculate Score", "score", "rescored"),
|
||||
new Action("crm_merge_contacts", "crm_contact", "Merge Contacts", "merge", "merged"),
|
||||
new Action("crm_detect_duplicates", "crm_contact", "Find Duplicates", "duplicates", "checked for duplicates"),
|
||||
new Action("crm_convert_lead", "crm_contact", "Create Deal", "createDeal", "given a new deal"),
|
||||
new Action("crm_gdpr_anonymize_contact", "crm_contact", "Erase Personal Data", "erase", "anonymized"),
|
||||
new Action("crm_send_email", "crm_contact", "Send Email", "email", "sent email"),
|
||||
new Action("crm_assign_company_owner", "crm_company", "Assign Owner", "assign", "assigned a new owner"),
|
||||
new Action("crm_merge_companies", "crm_company", "Merge Companies", "merge", "merged"),
|
||||
new Action("crm_find_duplicate_companies", "crm_company", "Find Duplicates", "duplicates", "checked for duplicates"),
|
||||
new Action("crm_log_company_activity", "crm_company", "Log Activity", "activity", "given a logged activity"),
|
||||
new Action("crm_move_deal_stage", "crm_deal", "Move Stage", "stage", "moved to the target stage"),
|
||||
new Action("crm_mark_deal_won", "crm_deal", "Mark Won", "won", "marked won"),
|
||||
new Action("crm_mark_deal_lost", "crm_deal", "Mark Lost", "lost", "marked lost"),
|
||||
new Action("crm_reopen_deal", "crm_deal", "Reopen", "reopen", "reopened"),
|
||||
new Action("crm_clone_deal", "crm_deal", "Clone", "clone", "cloned"),
|
||||
new Action("crm_transfer_ownership", "crm_deal", "Assign Owner", "assign", "assigned a new owner"),
|
||||
new Action("crm_manage_contact_on_deal", "crm_deal", "Add Contact", "addContact", "linked to the contact"),
|
||||
new Action("crm_log_deal_activity", "crm_deal", "Log Activity", "activity", "given a logged activity"),
|
||||
new Action("crm_convert_form_to_contact", "crm_form_submission", "Convert to Contact", "convert", "converted to contacts"),
|
||||
new Action("crm_activate_sequence", "crm_email_sequence", "Activate", "activate", "activated"),
|
||||
new Action("crm_pause_sequence", "crm_email_sequence", "Pause", "pause", "paused"));
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve exclusively by the installed process name, never client values.
|
||||
*******************************************************************************/
|
||||
public static Action action(String name)
|
||||
{
|
||||
return (ACTIONS.stream().filter(a -> a.name().equals(name)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-discovered metadata for every UI action.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance)
|
||||
{
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
ACTIONS.forEach(action -> output.add(metadata(action)));
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Input details precede the standard ETL Review and Result screens.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(Action action)
|
||||
{
|
||||
Map<String, Serializable> values = Map.of(
|
||||
StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, action.table(),
|
||||
StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, action.table(),
|
||||
StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true,
|
||||
StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true,
|
||||
StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT,
|
||||
StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, action.label() + " for the selected records.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(CrmAction.Extract.class, CrmAction.Transform.class, CrmAction.Load.class, values)
|
||||
.withName(action.name()).withLabel(action.label()).withTableName(action.table()).withMinInputRecords(1)
|
||||
.withIcon(new QIcon("play_arrow")).withPermissionRules(CrmPermissions.processRules(action.name()));
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
process.withMinInputRecords(2).withMaxInputRecords(2);
|
||||
}
|
||||
List<QFieldMetaData> fields = inputFields(action);
|
||||
if(!fields.isEmpty())
|
||||
{
|
||||
QFrontendStepMetaData details = new QFrontendStepMetaData().withName("details").withLabel(action.label())
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
|
||||
fields.forEach(details::withFormField);
|
||||
process.withStep(0, details);
|
||||
if(List.of("stage", "reopen", "merge").contains(action.operation()))
|
||||
{
|
||||
process.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(CrmAction.Prepare.class)));
|
||||
}
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required user input, including server-validated contingent stage choices.
|
||||
*******************************************************************************/
|
||||
public static List<QFieldMetaData> inputFields(Action action)
|
||||
{
|
||||
List<QFieldMetaData> fields = new ArrayList<>();
|
||||
switch(action.operation())
|
||||
{
|
||||
case "email" ->
|
||||
{
|
||||
fields.add(field("emailTemplateId", "Template", QFieldType.INTEGER, "crm_email_template").withIsRequired(false));
|
||||
fields.add(field("subject", "Subject", QFieldType.STRING, null).withIsRequired(false));
|
||||
fields.add(field("body", "Body", QFieldType.TEXT, null).withIsRequired(false));
|
||||
}
|
||||
case "assign" -> fields.add(field("newOwnerUserId", "Owner", QFieldType.STRING, null));
|
||||
case "subscribe", "unsubscribe" -> fields.add(field("emailListId", "List", QFieldType.INTEGER, "crm_email_list"));
|
||||
case "enroll", "unenroll" -> fields.add(field("sequenceId", "Sequence", QFieldType.INTEGER, "crm_email_sequence"));
|
||||
case "merge" -> fields.add(field("primaryId", "Record to Keep", QFieldType.INTEGER, action.table())
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, "${input.mergeIds}"))));
|
||||
case "stage", "reopen" ->
|
||||
{
|
||||
fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline").withIsEditable(false));
|
||||
fields.add(field("targetStageId", "Target Stage", QFieldType.INTEGER, "crm_pipeline_stage")
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}"))));
|
||||
}
|
||||
case "won", "lost" -> fields.add(field("winLossReasonId", "Win/Loss Reason", QFieldType.INTEGER, "crm_win_loss_reason"));
|
||||
case "createDeal" ->
|
||||
{
|
||||
fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline"));
|
||||
fields.add(field("dealName", "Deal Name", QFieldType.STRING, null));
|
||||
fields.add(field("dealAmount", "Amount", QFieldType.DECIMAL, null).withIsRequired(false));
|
||||
}
|
||||
case "addContact" ->
|
||||
{
|
||||
fields.add(field("contactId", "Contact", QFieldType.INTEGER, "crm_contact"));
|
||||
fields.add(field("contactRoleId", "Role", QFieldType.INTEGER, "crm_contact_role").withIsRequired(false));
|
||||
fields.add(field("isPrimary", "Primary Contact", QFieldType.BOOLEAN, null).withIsRequired(false));
|
||||
}
|
||||
case "activity" ->
|
||||
{
|
||||
fields.add(field("activityTypeId", "Activity Type", QFieldType.INTEGER, "crm_activity_type"));
|
||||
fields.add(field("subject", "Subject", QFieldType.STRING, null));
|
||||
fields.add(field("description", "Description", QFieldType.TEXT, null).withIsRequired(false));
|
||||
}
|
||||
default ->
|
||||
{
|
||||
}
|
||||
}
|
||||
return (fields);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consistent required field metadata.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, String label, QFieldType type, String pvs)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withLabel(label).withIsRequired(true).withPossibleValueSourceName(pvs));
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled process that fires reminders for activities with isReminderSet
|
||||
** = true and reminderDate <= now. Clears the reminder flag after processing.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for firing reminders. Intended to run on a
|
||||
** 5-minute schedule. Finds activities with active reminders whose
|
||||
** reminderDate has passed, clears the flag, and would fire notifications
|
||||
** in a production environment.
|
||||
*******************************************************************************/
|
||||
public class FireRemindersProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_fire_reminders";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Fire Reminders")
|
||||
.withIcon(new QIcon().withName("alarm"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(FireRemindersProcess.class))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: find activities with due reminders and clear the reminder flag.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// query activities with pending reminders //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isReminderSet", QCriteriaOperator.EQUALS, true))
|
||||
.withCriteria(new QFilterCriteria("reminderDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))
|
||||
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false))));
|
||||
|
||||
int firedCount = 0;
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
/////////////////////////////////////////////////////////////
|
||||
// In production this would fire a notification. For now, //
|
||||
// just clear the reminder flag. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", record.getValueInteger("id"))
|
||||
.withValue("isReminderSet", false);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
firedCount++;
|
||||
}
|
||||
|
||||
output.addValue("firedCount", firedCount);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,13 +27,7 @@
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityOutcome;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -42,62 +36,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep that creates a crm_activity record from
|
||||
** Backend operation used by the streamed CRM actions that creates a crm_activity record from
|
||||
** the provided input fields. Sets ownerUserId from the current session.
|
||||
*******************************************************************************/
|
||||
public class LogActivityProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class LogActivityProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_log_activity";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata with input fields for all activity fields.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Log Activity")
|
||||
.withTableName(Activity.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("event_note"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(LogActivityProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("activityTypeId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(ActivityType.TABLE_NAME),
|
||||
new QFieldMetaData("subject", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("description", QFieldType.STRING),
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withPossibleValueSourceName(Contact.TABLE_NAME),
|
||||
new QFieldMetaData("companyId", QFieldType.INTEGER).withPossibleValueSourceName(Company.TABLE_NAME),
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("direction", QFieldType.INTEGER).withPossibleValueSourceName(CrmDirection.NAME),
|
||||
new QFieldMetaData("durationMinutes", QFieldType.INTEGER),
|
||||
new QFieldMetaData("outcomeId", QFieldType.INTEGER).withPossibleValueSourceName(ActivityOutcome.TABLE_NAME),
|
||||
new QFieldMetaData("location", QFieldType.STRING),
|
||||
new QFieldMetaData("conferenceLink", QFieldType.STRING),
|
||||
new QFieldMetaData("startDate", QFieldType.DATE_TIME),
|
||||
new QFieldMetaData("endDate", QFieldType.DATE_TIME)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the process: build an Activity entity from inputs and insert it.
|
||||
*******************************************************************************/
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled process that finds overdue tasks and marks them with a system
|
||||
** note. In production this would fire notifications; for now it logs a
|
||||
** message on each overdue activity.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for processing overdue tasks. Intended to
|
||||
** run on an hourly schedule. Finds activities of type "Task" with dueDate
|
||||
** in the past and isCompleted=false.
|
||||
*******************************************************************************/
|
||||
public class ProcessOverdueTasksProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_process_overdue_tasks";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Process Overdue Tasks")
|
||||
.withIcon(new QIcon().withName("warning"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ProcessOverdueTasksProcess.class))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: find overdue tasks and mark them with a system note.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// look up the "Task" activity type //
|
||||
///////////////////////////////////////////////
|
||||
Integer taskTypeId = lookupTaskActivityTypeId();
|
||||
if(taskTypeId == null)
|
||||
{
|
||||
output.addValue("processedCount", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query overdue, incomplete tasks //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("activityTypeId", QCriteriaOperator.EQUALS, taskTypeId))
|
||||
.withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now()))
|
||||
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false))));
|
||||
|
||||
int processedCount = 0;
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// In production this would create a notification. For now, add a //
|
||||
// system note to the description indicating it is overdue. //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
String currentDescription = record.getValueString("description");
|
||||
String note = "[OVERDUE] Task past due date.";
|
||||
String newDescription = currentDescription != null ? currentDescription + " " + note : note;
|
||||
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", record.getValueInteger("id"))
|
||||
.withValue("description", newDescription);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
output.addValue("processedCount", processedCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Look up the "Task" activity type id. Returns null if not found.
|
||||
***************************************************************************/
|
||||
private Integer lookupTaskActivityTypeId() throws QException
|
||||
{
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(ActivityType.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Task"))));
|
||||
|
||||
if(!queryOutput.getRecords().isEmpty())
|
||||
{
|
||||
return (queryOutput.getRecords().get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
@@ -288,13 +287,14 @@ public class CrmIntakeApi
|
||||
QRecord contact;
|
||||
if(contacts.isEmpty())
|
||||
{
|
||||
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource();
|
||||
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource().strip();
|
||||
source = source.length() > 100 ? source.substring(0, 100) : source;
|
||||
List<QRecord> sources = store.by("crm_lead_source", "name", source);
|
||||
QRecord leadSource = sources.isEmpty() ? store.insert("crm_lead_source", new QRecord().withValue("name", source).withValue("sortOrder", 0)) : sources.get(0);
|
||||
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
|
||||
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
|
||||
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
|
||||
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
|
||||
.withValue("phone", request.phone()).withValue("ownerUserId", null)
|
||||
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
|
||||
}
|
||||
else
|
||||
@@ -323,7 +323,7 @@ public class CrmIntakeApi
|
||||
}
|
||||
update.withValue("lastTouchDate", Instant.now());
|
||||
store.update("crm_contact", update);
|
||||
if(contacts.isEmpty() && isUnassigned(contact.getValueString("ownerUserId")))
|
||||
if(contacts.isEmpty() && isUnassigned(store.get("crm_contact", contact.getValueInteger("id")).getValueString("ownerUserId")))
|
||||
{
|
||||
CrmAutoAssign.assign(CrmAssignableEntity.CONTACT, contact.getValueInteger("id"));
|
||||
}
|
||||
@@ -395,6 +395,6 @@ public class CrmIntakeApi
|
||||
*******************************************************************************/
|
||||
private String name(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? "Not provided" : value);
|
||||
return (value == null || value.isBlank() ? null : value.strip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.bulkload;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfile;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Starter mappings for the host's core bulk loader. The host owns its tables.
|
||||
*******************************************************************************/
|
||||
public final class CrmBulkLoadProfiles
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmBulkLoadProfiles()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Call once during host startup, after core profile tables are installed.
|
||||
** Existing profiles, including user edits, are preserved by label.
|
||||
*******************************************************************************/
|
||||
public static void ensure() throws QException
|
||||
{
|
||||
if(QContext.getQInstance().getTable(SavedBulkLoadProfile.TABLE_NAME) == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try(InputStream stream = CrmBulkLoadProfiles.class.getResourceAsStream("/bulkload/crm-profiles.json"))
|
||||
{
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
for(JsonNode profile : mapper.readTree(stream))
|
||||
{
|
||||
String label = profile.get("label").asText();
|
||||
if(!new QueryAction().execute(new QueryInput(SavedBulkLoadProfile.TABLE_NAME).withFilter(new QQueryFilter(
|
||||
new QFilterCriteria("label", QCriteriaOperator.EQUALS, label)).withLimit(1))).getRecords().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
BulkLoadProfile mapping = mapper.treeToValue(profile.get("mapping"), BulkLoadProfile.class);
|
||||
CrmRecords.insert(SavedBulkLoadProfile.TABLE_NAME, new QRecord().withValue("label", label)
|
||||
.withValue("tableName", profile.get("tableName").asText()).withValue("isBulkEdit", false)
|
||||
.withValue("mappingJson", mapper.writeValueAsString(mapping)));
|
||||
}
|
||||
}
|
||||
catch(IOException | IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("Could not load CRM bulk-load profiles", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.core;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Checked writes for background work: record errors must not advance checkpoints.
|
||||
*******************************************************************************/
|
||||
public final class CrmRecords
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmRecords()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert and require a successful persisted row.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord row) throws QException
|
||||
{
|
||||
return (check(new InsertAction().execute(new InsertInput(table).withRecord(row)).getRecords()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Update and require a successful persisted row.
|
||||
*******************************************************************************/
|
||||
public static QRecord update(String table, QRecord row) throws QException
|
||||
{
|
||||
return (check(new UpdateAction().execute(new UpdateInput(table).withRecord(row)).getRecords()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** DML errors are failed work, never successful job progress.
|
||||
*******************************************************************************/
|
||||
private static QRecord check(List<QRecord> rows) throws QException
|
||||
{
|
||||
if(rows.isEmpty() || rows.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QException("CRM write was rejected"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.core.customizers;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 recordLabelFormat is printf-only; preserve a computed query/get label.
|
||||
*******************************************************************************/
|
||||
public class ContactLabelCustomizer extends AbstractPostQueryCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep missing names absent in storage while giving records useful labels.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord visible = new QRecord().withValue("id", record.getValue("id"));
|
||||
for(String name : List.of("firstName", "lastName", "email"))
|
||||
{
|
||||
var field = QContext.getQInstance().getTable(Contact.TABLE_NAME).getField(name);
|
||||
if(FieldSecurityLockHelper.isFieldAccessible(field)
|
||||
&& !(input != null && input.getShouldOmitHiddenFields() && field.getIsHidden())
|
||||
&& !(input != null && input.getShouldMaskPasswords() && field.getType().needsMasked()))
|
||||
{
|
||||
visible.setValue(name, record.getValue(name));
|
||||
}
|
||||
}
|
||||
record.setRecordLabel(label(visible));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefer the supplied name, then email, then the record identifier.
|
||||
*******************************************************************************/
|
||||
public static String label(QRecord record)
|
||||
{
|
||||
String name = (Objects.toString(record.getValue("firstName"), "").strip() + " "
|
||||
+ Objects.toString(record.getValue("lastName"), "").strip()).strip();
|
||||
return (name.isBlank() ? Objects.toString(record.getValue("email"), Objects.toString(record.getValue("id"), "Contact")) : name);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.model;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
@@ -46,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -108,30 +104,7 @@ public class Company extends QRecordEntity
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// inject security fields and locks from CrmQBitConfig //
|
||||
///////////////////////////////////////////////////////////////
|
||||
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
|
||||
if(config != null)
|
||||
{
|
||||
if(config.getSecurityFields() != null)
|
||||
{
|
||||
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
|
||||
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
|
||||
{
|
||||
table.addField(fieldMetaData.clone());
|
||||
t1Section.getFieldNames().add(fieldMetaData.getName());
|
||||
}
|
||||
}
|
||||
|
||||
if(config.getRecordSecurityLocks() != null)
|
||||
{
|
||||
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
table.withRecordSecurityLock(lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (table);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ package com.kingsrook.qbits.crm.core.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
|
||||
import com.kingsrook.qbits.crm.privacy.CrmPersonalData.ErasureFlag;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer;
|
||||
@@ -111,6 +113,12 @@ public class ConsentRecord extends QRecordEntity
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records) throws QException
|
||||
{
|
||||
if(updateInput != null && updateInput.hasFlag(ErasureFlag.REDACT_CONSENT)
|
||||
&& records.stream().allMatch(row -> Set.of("id", "consentText").containsAll(row.getValues().keySet())
|
||||
&& "erased".equals(row.getValueString("consentText"))))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
throw new QUserFacingException("Consent records cannot be modified or deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailMessage;
|
||||
@@ -46,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -105,51 +101,25 @@ public class Contact extends QRecordEntity
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
QFieldSection t1Section = SectionFactory.defaultT1("id", "firstName", "lastName", "email", "phone");
|
||||
QFieldSection t1Section = SectionFactory.defaultT1("firstName", "lastName", "email", "phone");
|
||||
|
||||
table
|
||||
.withIcon(new QIcon().withName("person"))
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("firstName", "lastName")
|
||||
.withRecordLabelFormat(null)
|
||||
.withRecordLabelFields(List.of())
|
||||
.withSection(t1Section)
|
||||
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
|
||||
List.of("companyId", "jobTitle", "department", "lifecycleStageId", "leadSourceId",
|
||||
"ownerUserId", "leadScore", "timezone", "preferredLanguage")))
|
||||
.withSection(new QFieldSection("online", new QIcon().withName("language"), Tier.T2,
|
||||
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl")))
|
||||
.withSection(new QFieldSection("address", new QIcon().withName("location_on"), Tier.T2,
|
||||
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
|
||||
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
|
||||
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
|
||||
"ownerUserId", "leadScore", "timezone", "preferredLanguage", "description")))
|
||||
.withSection(new QFieldSection("contactInformation", "Contact Information", new QIcon("contact_mail"), Tier.T2,
|
||||
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl", "addressLine1", "addressLine2", "city", "state", "postalCode", "country",
|
||||
"doNotEmail", "doNotCall", "unsubscribeDate")))
|
||||
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
|
||||
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
|
||||
.withSection(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3,
|
||||
List.of("id", "lastActivityDate", "lastContactedDate", "createDate", "modifyDate")));
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// inject security fields and locks from CrmQBitConfig //
|
||||
///////////////////////////////////////////////////////////////
|
||||
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
|
||||
if(config != null)
|
||||
{
|
||||
if(config.getSecurityFields() != null)
|
||||
{
|
||||
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
|
||||
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
|
||||
{
|
||||
table.addField(fieldMetaData.clone());
|
||||
t1Section.getFieldNames().add(fieldMetaData.getName());
|
||||
}
|
||||
}
|
||||
|
||||
if(config.getRecordSecurityLocks() != null)
|
||||
{
|
||||
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
table.withRecordSecurityLock(lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (table);
|
||||
}
|
||||
@@ -157,19 +127,19 @@ public class Contact extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmContent;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmTerm;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@@ -181,19 +151,19 @@ public class Contact extends QRecordEntity
|
||||
@QField()
|
||||
private Instant firstTouchDate;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmContent;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmTerm;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@@ -208,16 +178,16 @@ public class Contact extends QRecordEntity
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastName;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String email;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String secondaryEmail;
|
||||
|
||||
@QField(maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@@ -241,7 +211,7 @@ public class Contact extends QRecordEntity
|
||||
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
|
||||
private Integer leadSourceId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
@QField()
|
||||
private String ownerUserId;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
|
||||
@@ -106,7 +106,7 @@ public class Currency extends QRecordEntity
|
||||
@QField(isRequired = true, defaultValue = "true")
|
||||
private Boolean isActive;
|
||||
|
||||
@QField()
|
||||
@QField(isEditable = false)
|
||||
private Instant rateLastUpdatedDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
@@ -123,7 +123,7 @@ public class EmailListMember extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer contactId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String email;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
|
||||
@@ -129,7 +129,7 @@ public class FormSubmission extends QRecordEntity
|
||||
@QField(isRequired = true, possibleValueSourceName = CrmFormType.NAME)
|
||||
private Integer formType;
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String email;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@@ -150,19 +150,19 @@ public class FormSubmission extends QRecordEntity
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String sourceUrl;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String utmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String utmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String utmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String utmTerm;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String utmContent;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
|
||||
@@ -40,7 +40,13 @@ public enum CrmEmailStatus implements PossibleValueEnum<Integer>
|
||||
DRAFT(1, "Draft"),
|
||||
SENT(2, "Sent"),
|
||||
DELIVERED(3, "Delivered"),
|
||||
FAILED(4, "Failed");
|
||||
FAILED(4, "Failed"),
|
||||
QUEUED(5, "Queued"),
|
||||
BOUNCED(6, "Bounced"),
|
||||
COMPLAINED(7, "Complained"),
|
||||
OPENED(8, "Opened"),
|
||||
CLICKED(9, "Clicked"),
|
||||
SUPPRESSED(10, "Suppressed");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bulk action process to assign a new owner to selected records. Works on
|
||||
** any table that has an ownerUserId field.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer for the BulkAssignOwner table-level process. Uses the
|
||||
** StreamedETL pattern with a transform step that sets ownerUserId on each
|
||||
** record to the newOwnerUserId input value.
|
||||
*******************************************************************************/
|
||||
public class BulkAssignOwnerProcess implements MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_bulk_assign_owner";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
|
||||
.withName(NAME)
|
||||
.withLabel("Bulk Assign Owner")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("person_pin"))
|
||||
.withTransformStepClass(BulkAssignOwnerTransformStep.class)
|
||||
.withPreviewMessage("Records to be assigned a new owner")
|
||||
.withReviewStepRecordFields(List.of(
|
||||
new QFieldMetaData("id", QFieldType.INTEGER),
|
||||
new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Current Owner")
|
||||
))
|
||||
.getProcessMetaData();
|
||||
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform step that sets ownerUserId on each record.
|
||||
*******************************************************************************/
|
||||
public static class BulkAssignOwnerTransformStep extends AbstractTransformStep
|
||||
{
|
||||
private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
|
||||
.withSingularFutureMessage("will be assigned a new owner")
|
||||
.withPluralFutureMessage("will be assigned a new owner")
|
||||
.withSingularPastMessage("was assigned a new owner")
|
||||
.withPluralPastMessage("were assigned a new owner");
|
||||
|
||||
/*******************************************************************************
|
||||
** Set ownerUserId on each record from the newOwnerUserId input.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String newOwnerUserId = input.getValueString("newOwnerUserId");
|
||||
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
record.setValue("ownerUserId", newOwnerUserId);
|
||||
output.addRecord(record);
|
||||
okLine.incrementCountAndAddPrimaryKey(record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provide process summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
okLine.addSelfToListIfAnyCount(rs);
|
||||
return (rs);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+2
-35
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
@@ -54,52 +53,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for converting a form submission into a
|
||||
** Backend operation used by the streamed CRM actions for converting a form submission into a
|
||||
** contact record. Deduplicates on email, updates the form with conversion
|
||||
** data, and creates a Note activity on the contact.
|
||||
*******************************************************************************/
|
||||
public class ConvertFormToContactProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ConvertFormToContactProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_convert_form_to_contact";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Convert Form to Contact")
|
||||
.withTableName(FormSubmission.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("swap_horiz"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ConvertFormToContactProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("formSubmissionId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the conversion process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -41,49 +41,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for detecting duplicate contacts or companies.
|
||||
** Backend operation used by the streamed CRM actions for detecting duplicate contacts or companies.
|
||||
*******************************************************************************/
|
||||
public class DetectDuplicatesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class DetectDuplicatesProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_detect_duplicates";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Detect Duplicates")
|
||||
.withIcon(new QIcon().withName("find_replace"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(DetectDuplicatesProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the detect duplicates process.
|
||||
*******************************************************************************/
|
||||
|
||||
+5
-155
@@ -18,180 +18,30 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GDPR right-to-erasure process. Anonymizes PII on a contact, scrubs audit
|
||||
** log entries, deletes attachments, unenrolls from sequences, creates a
|
||||
** consent-withdrawn record, and logs the anonymization event.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.ConsentRecord;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.privacy.CrmPersonalData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for GDPR anonymization of a contact.
|
||||
** Backend operation for the streamed Erase Personal Data action.
|
||||
*******************************************************************************/
|
||||
public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class GdprAnonymizeContactProcess implements BackendStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class);
|
||||
|
||||
public static final String NAME = "crm_gdpr_anonymize_contact";
|
||||
|
||||
private static final String ANONYMIZED = "[ANONYMIZED]";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("GDPR Anonymize Contact")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("privacy_tip"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(GdprAnonymizeContactProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the GDPR anonymization process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer contactId = input.getValueInteger("contactId");
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Step 1: load and validate contact //
|
||||
///////////////////////////////////////////
|
||||
QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId);
|
||||
if(contactRecord == null)
|
||||
{
|
||||
throw new QException("Contact not found: " + contactId);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Step 2: anonymize PII fields //
|
||||
///////////////////////////////////////////
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", contactId)
|
||||
.withValue("firstName", ANONYMIZED)
|
||||
.withValue("lastName", ANONYMIZED)
|
||||
.withValue("email", "anonymized-" + contactId + "@removed.invalid")
|
||||
.withValue("phone", null)
|
||||
.withValue("mobilePhone", null)
|
||||
.withValue("secondaryEmail", null)
|
||||
.withValue("addressLine1", null)
|
||||
.withValue("addressLine2", null)
|
||||
.withValue("city", null)
|
||||
.withValue("state", null)
|
||||
.withValue("postalCode", null)
|
||||
.withValue("country", null)
|
||||
.withValue("website", null)
|
||||
.withValue("linkedinUrl", null)
|
||||
.withValue("description", null)
|
||||
.withValue("doNotEmail", true)
|
||||
.withValue("doNotCall", true);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Step 3: unenroll from sequences //
|
||||
///////////////////////////////////////////
|
||||
unenrollFromSequences(contactId);
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Step 4: create consent record //
|
||||
///////////////////////////////////////////
|
||||
new InsertAction().execute(
|
||||
new InsertInput(ConsentRecord.TABLE_NAME).withRecordEntity(
|
||||
new ConsentRecord()
|
||||
.withContactId(contactId)
|
||||
.withConsentType(CrmConsentType.DATA_PROCESSING.getPossibleValueId())
|
||||
.withStatus(CrmConsentStatus.WITHDRAWN.getPossibleValueId())
|
||||
.withSource("GDPR Anonymization Process")
|
||||
.withConsentDate(Instant.now())));
|
||||
|
||||
Integer contactId = input.getValueInteger("contactId");
|
||||
CrmPersonalData.erase(contactId);
|
||||
output.addValue("contactId", contactId);
|
||||
output.addValue("anonymized", true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Unenroll from any active sequence enrollments for this contact.
|
||||
***************************************************************************/
|
||||
private void unenrollFromSequences(Integer contactId) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
QueryOutput enrollments = new QueryAction().execute(
|
||||
new QueryInput("crm_sequence_enrollment")
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))));
|
||||
|
||||
for(QRecord record : enrollments.getRecords())
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", record.getValueInteger("id"))
|
||||
.withValue("status", CrmEnrollmentStatus.UNENROLLED.getPossibleValueId());
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput("crm_sequence_enrollment").withRecord(updateRecord));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error unenrolling contact from sequences during GDPR anonymization", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Simplified import process for contacts. Takes a list of QRecords,
|
||||
** validates required fields, checks for duplicates by email, inserts new
|
||||
** contacts, with core table auditing.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for importing contacts from QRecords.
|
||||
*******************************************************************************/
|
||||
public class ImportContactsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_import_contacts";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Import Contacts")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("upload"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ImportContactsProcess.class))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the import contacts process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> inputRecords = input.getRecords();
|
||||
String sessionUserId = QContext.getQSession().getIdReference();
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// build set of existing emails for duplicate detection //
|
||||
////////////////////////////////////////////////////////////
|
||||
Set<String> existingEmails = new HashSet<>();
|
||||
QueryOutput existingContacts = new QueryAction().execute(
|
||||
new QueryInput(Contact.TABLE_NAME));
|
||||
for(QRecord record : existingContacts.getRecords())
|
||||
{
|
||||
String email = record.getValueString("email");
|
||||
if(StringUtils.hasContent(email))
|
||||
{
|
||||
existingEmails.add(email.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
int importedCount = 0;
|
||||
int skippedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for(QRecord inputRecord : inputRecords)
|
||||
{
|
||||
String firstName = inputRecord.getValueString("firstName");
|
||||
String lastName = inputRecord.getValueString("lastName");
|
||||
String email = inputRecord.getValueString("email");
|
||||
|
||||
///////////////////////////////////////////
|
||||
// validate required fields //
|
||||
///////////////////////////////////////////
|
||||
if(!StringUtils.hasContent(firstName) || !StringUtils.hasContent(lastName) || !StringUtils.hasContent(email))
|
||||
{
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// check for duplicate by email //
|
||||
///////////////////////////////////////////
|
||||
if(existingEmails.contains(email.toLowerCase()))
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// insert the new contact //
|
||||
///////////////////////////////////////////
|
||||
QRecord newContact = new QRecord()
|
||||
.withValue("firstName", firstName)
|
||||
.withValue("lastName", lastName)
|
||||
.withValue("email", email)
|
||||
.withValue("phone", inputRecord.getValueString("phone"))
|
||||
.withValue("ownerUserId", sessionUserId);
|
||||
|
||||
new InsertAction().execute(
|
||||
new InsertInput(Contact.TABLE_NAME).withRecord(newContact));
|
||||
|
||||
existingEmails.add(email.toLowerCase());
|
||||
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
output.addValue("importedCount", importedCount);
|
||||
output.addValue("skippedCount", skippedCount);
|
||||
output.addValue("errorCount", errorCount);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -50,50 +50,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for merging two companies into one.
|
||||
** Backend operation used by the streamed CRM actions for merging two companies into one.
|
||||
*******************************************************************************/
|
||||
public class MergeCompaniesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class MergeCompaniesProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_merge_companies";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Merge Companies")
|
||||
.withTableName(Company.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("merge"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(MergeCompaniesProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("primaryCompanyId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("secondaryCompanyId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the merge companies process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -52,50 +52,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for merging two contacts into one.
|
||||
** Backend operation used by the streamed CRM actions for merging two contacts into one.
|
||||
*******************************************************************************/
|
||||
public class MergeContactsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class MergeContactsProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_merge_contacts";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Merge Contacts")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("merge"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(MergeContactsProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("primaryContactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("secondaryContactId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the merge contacts process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.EmailListMember;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
@@ -47,54 +46,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to subscribe an email address to a list.
|
||||
** Backend operation used by the streamed CRM actions to subscribe an email address to a list.
|
||||
** Handles: new subscription, resubscription after unsub, and no-op for
|
||||
** existing active subscribers.
|
||||
*******************************************************************************/
|
||||
public class SubscribeToListProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class SubscribeToListProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_subscribe_to_list";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Subscribe to List")
|
||||
.withIcon(new QIcon().withName("person_add"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(SubscribeToListProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("firstName", QFieldType.STRING),
|
||||
new QFieldMetaData("subscriptionSource", QFieldType.STRING)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the subscription logic.
|
||||
*******************************************************************************/
|
||||
|
||||
+2
-35
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.EmailListMember;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
@@ -44,51 +43,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to unsubscribe an email from a list.
|
||||
** Backend operation used by the streamed CRM actions to unsubscribe an email from a list.
|
||||
** Queries by emailListId + email, sets status to UNSUBSCRIBED and
|
||||
** unsubscribedDate to now.
|
||||
*******************************************************************************/
|
||||
public class UnsubscribeFromListProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class UnsubscribeFromListProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_unsubscribe_from_list";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Unsubscribe from List")
|
||||
.withIcon(new QIcon().withName("unsubscribe"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(UnsubscribeFromListProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the unsubscription.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.deals;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deal age notifications with a persistent once-per-deal-and-stage checkpoint.
|
||||
*******************************************************************************/
|
||||
public final class CrmDealNotifications
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmDealNotifications()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan open deals in keyset pages, resolving stage metadata once per run.
|
||||
*******************************************************************************/
|
||||
public static void checkRotting() throws QException
|
||||
{
|
||||
Map<Integer, QRecord> stages = new HashMap<>();
|
||||
for(QRecord stage : new QueryAction().execute(new QueryInput("crm_pipeline_stage")).getRecords())
|
||||
{
|
||||
stages.put(stage.getValueInteger("id"), stage);
|
||||
}
|
||||
Integer lastId = null;
|
||||
Instant now = Instant.now();
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_deal").withFilter(filter)).getRecords();
|
||||
for(QRecord deal : page)
|
||||
{
|
||||
lastId = deal.getValueInteger("id");
|
||||
QRecord stage = stages.get(deal.getValueInteger("pipelineStageId"));
|
||||
Instant entered = deal.getValueInstant("stageEnteredDate");
|
||||
if(stage == null || entered == null || deal.getValueString("ownerUserId") == null
|
||||
|| Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Integer days = stage.getValueInteger("rotDays");
|
||||
if(days == null || days <= 0 || !entered.isBefore(now.minus(days, ChronoUnit.DAYS)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String key = "deal:" + lastId + ":stage:" + stage.getValue("id");
|
||||
String checkpoint = "crm.deal.rotting:" + key;
|
||||
if(new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, checkpoint)))).getRecords().isEmpty())
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Deal needs attention").withSourceName("crm.deal.rotting").withSourceKey(key)
|
||||
.withSourceTableName("crm_deal").withSourceRecordId(lastId.toString())
|
||||
.withAudience(NotificationAudience.user(deal.getValueString("ownerUserId"))));
|
||||
CrmRecords.insert(JobState.TABLE_NAME, new QRecord().withValue("name", checkpoint).withValue("lastSuccess", now));
|
||||
}
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 //
|
||||
///////////////////////////////////////////
|
||||
|
||||
+11
@@ -51,6 +51,9 @@ import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
@@ -192,6 +195,14 @@ public class DealStageChangeCustomizer extends AbstractPreUpdateCustomizer
|
||||
// insert DealStageHistory row //
|
||||
///////////////////////////////////////////
|
||||
insertStageHistoryRow(record, oldRecord, oldStageId, newStageId, now);
|
||||
String owner = record.getValues().containsKey("ownerUserId") ? record.getValueString("ownerUserId") : oldRecord.getValueString("ownerUserId");
|
||||
if(Boolean.TRUE.equals(newStage.getIsClosedWon()) && owner != null)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Deal won").withSourceName("crm.deal.won")
|
||||
.withSourceKey("deal:" + record.getValue("id") + ":stage:" + newStageId + ":" + now)
|
||||
.withSourceTableName(Deal.TABLE_NAME).withSourceRecordId(record.getValueString("id"))
|
||||
.withAudience(NotificationAudience.user(owner)), updateInput == null ? null : updateInput.getTransaction());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
@@ -52,13 +51,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -125,30 +121,7 @@ public class Deal extends QRecordEntity
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
.withSection(SectionFactory.defaultT3("stageEnteredDate", "lastActivityDate", "createDate", "modifyDate"));
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// inject security fields and locks from CrmQBitConfig //
|
||||
///////////////////////////////////////////////////////////////
|
||||
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
|
||||
if(config != null)
|
||||
{
|
||||
if(config.getSecurityFields() != null)
|
||||
{
|
||||
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
|
||||
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
|
||||
{
|
||||
table.addField(fieldMetaData.clone());
|
||||
t1Section.getFieldNames().add(fieldMetaData.getName());
|
||||
}
|
||||
}
|
||||
|
||||
if(config.getRecordSecurityLocks() != null)
|
||||
{
|
||||
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
table.withRecordSecurityLock(lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Only offer stages belonging to the selected pipeline. //
|
||||
@@ -156,6 +129,9 @@ public class Deal extends QRecordEntity
|
||||
table.getField("pipelineStageId").withPossibleValueSourceFilter(new QQueryFilter(
|
||||
new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}")));
|
||||
|
||||
table.getField("primaryContactId").withPossibleValueSourceFilter(new QQueryFilter(
|
||||
new QFilterCriteria("companyId", QCriteriaOperator.EQUALS, "${input.companyId}")));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +171,7 @@ public class Deal extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer primaryContactId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
@QField()
|
||||
private String ownerUserId;
|
||||
|
||||
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
|
||||
|
||||
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.deals.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealContact;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
@@ -50,49 +49,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for cloning a deal and its child records.
|
||||
** Backend operation used by the streamed CRM actions for cloning a deal and its child records.
|
||||
*******************************************************************************/
|
||||
public class CloneDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class CloneDealProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_clone_deal";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Clone Deal")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("content_copy"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(CloneDealProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the clone deal process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -30,7 +30,6 @@ package com.kingsrook.qbits.crm.deals.processes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.LifecycleStage;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
@@ -57,55 +56,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for converting a lead (contact) into a
|
||||
** Backend operation used by the streamed CRM actions for converting a lead (contact) into a
|
||||
** deal opportunity. Transitions the contact's lifecycle stage, creates
|
||||
** a deal with the first pipeline stage, and links them via DealContact.
|
||||
*******************************************************************************/
|
||||
public class ConvertLeadProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ConvertLeadProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_convert_lead";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Convert Lead")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("trending_up"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ConvertLeadProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("pipelineId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("dealName", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("dealAmount", QFieldType.DECIMAL)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the lead conversion process.
|
||||
*******************************************************************************/
|
||||
|
||||
+2
-38
@@ -50,55 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for adding or removing a contact on a deal.
|
||||
** Backend operation used by the streamed CRM actions for adding or removing a contact on a deal.
|
||||
** ADD: inserts a DealContact row, with isPrimary enforcement.
|
||||
** REMOVE: deletes the DealContact row matching dealId + contactId.
|
||||
*******************************************************************************/
|
||||
public class ManageContactOnDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ManageContactOnDealProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_manage_contact_on_deal";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Manage Contact on Deal")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("person_add"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ManageContactOnDealProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("action", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("contactRoleId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("isPrimary", QFieldType.BOOLEAN)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the manage contact process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -50,55 +50,22 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for moving a deal to a new pipeline stage.
|
||||
** Backend operation used by the streamed CRM actions for moving a deal to a new pipeline stage.
|
||||
** Enforces stage-belongs-to-pipeline validation, required field gating,
|
||||
** weighted amount recalculation, close-date stamping, and stage history
|
||||
** tracking.
|
||||
*******************************************************************************/
|
||||
public class MoveDealStageProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class MoveDealStageProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_move_deal_stage";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Move Deal Stage")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("swap_horiz"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(MoveDealStageProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the stage move process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -31,7 +31,6 @@ package com.kingsrook.qbits.crm.deals.processes;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
@@ -47,52 +46,19 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for reopening a closed deal by moving it
|
||||
** Backend operation used by the streamed CRM actions for reopening a closed deal by moving it
|
||||
** to an active pipeline stage. Validates the deal is currently closed, the
|
||||
** target stage is not closed, clears close data, and logs the transition.
|
||||
*******************************************************************************/
|
||||
public class ReopenDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ReopenDealProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_reopen_deal";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Reopen Deal")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("replay"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ReopenDealProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the reopen process.
|
||||
*******************************************************************************/
|
||||
|
||||
+2
-35
@@ -27,7 +27,6 @@
|
||||
package com.kingsrook.qbits.crm.deals.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
|
||||
@@ -46,50 +45,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for transferring ownership of contacts,
|
||||
** Backend operation used by the streamed CRM actions for transferring ownership of contacts,
|
||||
** companies, and deals from one user to another.
|
||||
*******************************************************************************/
|
||||
public class TransferOwnershipProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class TransferOwnershipProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_transfer_ownership";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Transfer Ownership")
|
||||
.withIcon(new QIcon().withName("swap_horiz"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(TransferOwnershipProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("fromUserId", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("toUserId", QFieldType.STRING).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the transfer ownership process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Carry the core process UUID across Review/Result and retries in process state.
|
||||
*******************************************************************************/
|
||||
public class CrmEmailProcessIdentity implements ProcessLifecycleListenerInterface
|
||||
{
|
||||
@Override
|
||||
public boolean appliesTo(String processName)
|
||||
{
|
||||
return ("crm_send_email".equals(processName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProcessStarted(RunProcessInput input)
|
||||
{
|
||||
input.getProcessState().getValues().put("crmEmailProcessUUID", input.getProcessUUID());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProcessCompleted(RunProcessInput input, RunProcessOutput output)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProcessFailed(RunProcessInput input, Exception exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,11 @@
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiEffectOutcome;
|
||||
@@ -87,6 +90,14 @@ public class CrmEmailTransport
|
||||
** Deliver one message, using a stable CRM purpose/id key for the durable email outbox.
|
||||
*******************************************************************************/
|
||||
public void send(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
deliver(purpose, id, contactId, address, subject, body, marketing);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return the transport result without guessing that queued email was sent.
|
||||
*******************************************************************************/
|
||||
private Delivery deliver(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
if(emailInstalled())
|
||||
{
|
||||
@@ -98,7 +109,9 @@ public class CrmEmailTransport
|
||||
{
|
||||
throw (new QUserFacingException("The email service could not accept this message."));
|
||||
}
|
||||
return;
|
||||
var message = result.message();
|
||||
return (message == null ? new Delivery(null, CrmEmailStatus.QUEUED, null, null, null, null, null)
|
||||
: new Delivery(String.valueOf(message.id()), status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null));
|
||||
}
|
||||
if(readiness() != CrmReadiness.CORE_MESSAGING)
|
||||
{
|
||||
@@ -107,5 +120,52 @@ public class CrmEmailTransport
|
||||
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName(config.getMessagingProviderName())
|
||||
.withTo(new Party().withAddress(address).withRole(EmailPartyRole.TO)).withSubject(subject)
|
||||
.withContent(new Content().withBody(body).withContentRole(EmailContentRole.TEXT)));
|
||||
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM-owned delivery snapshot; optional email types never escape this adapter.
|
||||
*******************************************************************************/
|
||||
public record Delivery(String messageId, CrmEmailStatus status, Instant sentDate, Instant deliveredDate,
|
||||
Instant openedDate, Instant clickedDate, Instant bouncedDate)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send and return the actual outbox state, or the successful core send state.
|
||||
*******************************************************************************/
|
||||
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
send(purpose, id, contactId, address, subject, body, marketing);
|
||||
return (lookup("crm:" + purpose + ":" + id).orElseGet(() ->
|
||||
new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read delivery through the published API only. RC.3 exposes sent/delivered dates.
|
||||
*******************************************************************************/
|
||||
public Optional<Delivery> lookup(String key) throws QException
|
||||
{
|
||||
if(!emailInstalled() || key == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
return (EmailApi.getMessageByIdempotencyKey(key).map(message -> new Delivery(String.valueOf(message.id()),
|
||||
status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve known delivery states and leave unknown/pending states eligible for sync.
|
||||
*******************************************************************************/
|
||||
private CrmEmailStatus status(String status)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (CrmEmailStatus.valueOf(status));
|
||||
}
|
||||
catch(IllegalArgumentException | NullPointerException e)
|
||||
{
|
||||
return (CrmEmailStatus.QUEUED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.api.ActivityKind;
|
||||
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared CRM template rendering, transport delivery and timeline recording.
|
||||
*******************************************************************************/
|
||||
public final class CrmEmails
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmEmails()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A skip reason suitable for standard WARNING summary lines.
|
||||
*******************************************************************************/
|
||||
public static String skipReason(QRecord contact, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(contact.getValueBoolean("doNotEmail")))
|
||||
{
|
||||
return ("Contact has opted out of email");
|
||||
}
|
||||
if(contact.getValueString("email") == null || contact.getValueString("email").isBlank())
|
||||
{
|
||||
return ("Contact has no email address");
|
||||
}
|
||||
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED ? "CRM email is not configured" : null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send once using the caller's stable purpose/id and record the transport outcome.
|
||||
*******************************************************************************/
|
||||
public static void send(CrmEmailTransport transport, QRecord contact, Integer templateId, String subject, String body,
|
||||
String purpose, String id, Integer enrollmentId) throws QException
|
||||
{
|
||||
String key = "crm:" + purpose + ":" + id;
|
||||
List<QRecord> existing = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(
|
||||
new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.EQUALS, key)))).getRecords();
|
||||
if(!existing.isEmpty())
|
||||
{
|
||||
recordActivity(existing.getFirst());
|
||||
return;
|
||||
}
|
||||
if(templateId != null)
|
||||
{
|
||||
QRecord template = GetAction.execute("crm_email_template", templateId);
|
||||
if(template == null)
|
||||
{
|
||||
throw (new QUserFacingException("Email template is unavailable"));
|
||||
}
|
||||
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
|
||||
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
|
||||
if(body == null)
|
||||
{
|
||||
body = template.getValueString("bodyHtml");
|
||||
}
|
||||
}
|
||||
if(subject == null || subject.isBlank() || body == null || body.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Email subject and body are required"));
|
||||
}
|
||||
subject = render(subject, contact);
|
||||
body = render(body, contact);
|
||||
CrmEmailTransport.Delivery delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
|
||||
contact.getValueString("email"), subject, body, enrollmentId != null);
|
||||
QRecord message = CrmRecords.insert("crm_email_message", new QRecord().withValue("contactId", contact.getValue("id"))
|
||||
.withValue("emailTemplateId", templateId).withValue("sequenceEnrollmentId", enrollmentId)
|
||||
.withValue("toAddresses", contact.getValue("email")).withValue("subject", subject)
|
||||
.withValue("direction", CrmDirection.OUTBOUND.getId()).withValue("status", delivery.status().getId())
|
||||
.withValue("idempotencyKey", key).withValue("emailMessageId", delivery.messageId())
|
||||
.withValue("sentDate", delivery.sentDate()).withValue("deliveredDate", delivery.deliveredDate()));
|
||||
recordActivity(message);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Repair a partially recorded send without sending again or duplicating its activity.
|
||||
*******************************************************************************/
|
||||
private static void recordActivity(QRecord message) throws QException
|
||||
{
|
||||
if(message.getValue("activityId") != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String description = "Email: " + message.getValueString("idempotencyKey");
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, message.getValue("contactId")),
|
||||
new QFilterCriteria("description", QCriteriaOperator.EQUALS, description));
|
||||
List<QRecord> activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
|
||||
if(activities.isEmpty())
|
||||
{
|
||||
new CrmMarketingApi().recordActivity(message.getValueInteger("contactId"),
|
||||
message.getValue("sequenceEnrollmentId") == null ? ActivityKind.EMAIL : ActivityKind.SEQUENCE_STEP,
|
||||
message.getValueString("subject"), description);
|
||||
activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
|
||||
}
|
||||
CrmRecords.update("crm_email_message", new QRecord().withValue("id", message.getValue("id"))
|
||||
.withValue("activityId", activities.getFirst().getValue("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Literal replacements avoid treating customer data as a template program.
|
||||
*******************************************************************************/
|
||||
public static String render(String text, QRecord contact) throws QException
|
||||
{
|
||||
QRecord company = contact.getValue("companyId") == null ? null : GetAction.execute("crm_company", contact.getValue("companyId"));
|
||||
Map<String, String> fields = Map.of("contact.firstName", value(contact, "firstName"), "contact.lastName", value(contact, "lastName"),
|
||||
"company.name", value(company, "name"), "owner.name", value(contact, "ownerUserId"));
|
||||
// Replace each token in the original text only, so inserted values cannot introduce merge fields.
|
||||
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}").matcher(text);
|
||||
StringBuilder rendered = new StringBuilder();
|
||||
while(matcher.find())
|
||||
{
|
||||
String replacement = "unsubscribeUrl".equals(matcher.group(1))
|
||||
? new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null) : fields.get(matcher.group(1));
|
||||
matcher.appendReplacement(rendered, java.util.regex.Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(rendered);
|
||||
return (rendered.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing merge values render as empty strings.
|
||||
*******************************************************************************/
|
||||
private static String value(QRecord row, String field)
|
||||
{
|
||||
return (row == null || row.getValueString(field) == null ? "" : row.getValueString(field));
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -75,6 +76,7 @@ public class EmailMessage extends QRecordEntity
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
table
|
||||
.withUniqueKey(new UniqueKey("idempotencyKey"))
|
||||
.withIcon(new QIcon().withName("email"))
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("subject")
|
||||
@@ -85,10 +87,10 @@ public class EmailMessage extends QRecordEntity
|
||||
List.of("bodyHtml", "bodyText")))
|
||||
.withSection(new QFieldSection("tracking", new QIcon("visibility"), Tier.T2,
|
||||
List.of("isTracked", "openCount", "clickCount", "firstOpenedDate", "firstClickedDate",
|
||||
"bounceType", "isSpamComplaint")))
|
||||
"bounceType", "isSpamComplaint", "deliveredDate", "bouncedDate")))
|
||||
.withSection(new QFieldSection("references", new QIcon("link"), Tier.T2,
|
||||
List.of("contactId", "activityId", "emailTemplateId", "sequenceEnrollmentId",
|
||||
"threadId", "replyToEmailId")))
|
||||
"threadId", "replyToEmailId", "idempotencyKey", "emailMessageId")))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
return (table);
|
||||
@@ -106,7 +108,7 @@ public class EmailMessage extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer contactId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String fromAddress;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@@ -172,6 +174,18 @@ public class EmailMessage extends QRecordEntity
|
||||
@QField()
|
||||
private Integer replyToEmailId;
|
||||
|
||||
@QField(maxLength = 250)
|
||||
private String idempotencyKey;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String emailMessageId;
|
||||
|
||||
@QField()
|
||||
private Instant deliveredDate;
|
||||
|
||||
@QField()
|
||||
private Instant bouncedDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -1031,4 +1045,103 @@ public class EmailMessage extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return (idempotencyKey);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get emailMessageId.
|
||||
*******************************************************************************/
|
||||
public String getEmailMessageId()
|
||||
{
|
||||
return (emailMessageId);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set emailMessageId.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withEmailMessageId(String emailMessageId)
|
||||
{
|
||||
this.emailMessageId = emailMessageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get deliveredDate.
|
||||
*******************************************************************************/
|
||||
public Instant getDeliveredDate()
|
||||
{
|
||||
return (deliveredDate);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveredDate.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withDeliveredDate(Instant deliveredDate)
|
||||
{
|
||||
this.deliveredDate = deliveredDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get bouncedDate.
|
||||
*******************************************************************************/
|
||||
public Instant getBouncedDate()
|
||||
{
|
||||
return (bouncedDate);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set bouncedDate.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withBouncedDate(Instant bouncedDate)
|
||||
{
|
||||
this.bouncedDate = bouncedDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set idempotencyKey for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set emailMessageId for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setEmailMessageId(String emailMessageId)
|
||||
{
|
||||
this.emailMessageId = emailMessageId;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveredDate for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setDeliveredDate(Instant deliveredDate)
|
||||
{
|
||||
this.deliveredDate = deliveredDate;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set bouncedDate for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setBouncedDate(Instant bouncedDate)
|
||||
{
|
||||
this.bouncedDate = bouncedDate;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailSequence;
|
||||
@@ -51,52 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to enroll a contact in an email sequence.
|
||||
** Backend operation used by the streamed CRM actions to enroll a contact in an email sequence.
|
||||
** Validates that the contact is not already enrolled, the sequence is active,
|
||||
** and the contact has not opted out of email.
|
||||
*******************************************************************************/
|
||||
public class EnrollInSequenceProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class EnrollInSequenceProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_enroll_in_sequence";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Enroll in Sequence")
|
||||
.withIcon(new QIcon().withName("playlist_add"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(EnrollInSequenceProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("sequenceId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the enrollment logic.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled CRM maintenance: crm_expire_consents.
|
||||
*******************************************************************************/
|
||||
public class ExpireConsentsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_expire_consents";
|
||||
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance instance)
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withLabel("ExpireConsents").withIsHidden(true)
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(ExpireConsentsProcess.class))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
Instant cutoff = now.minus(14, ChronoUnit.DAYS);
|
||||
Integer lastId = null;
|
||||
int expired = 0;
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmListMemberStatus.PENDING_CONFIRMATION.getId()))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_list_member").withFilter(filter)).getRecords();
|
||||
for(QRecord member : page)
|
||||
{
|
||||
lastId = member.getValueInteger("id");
|
||||
Instant pendingSince = member.getValueInstant("confirmationSentDate");
|
||||
if(pendingSince == null)
|
||||
{
|
||||
pendingSince = member.getValueInstant("createDate");
|
||||
}
|
||||
if(pendingSince != null && !pendingSince.isAfter(cutoff))
|
||||
{
|
||||
CrmRecords.update("crm_email_list_member", new QRecord().withValue("id", lastId)
|
||||
.withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()).withValue("confirmationTokenHash", null)
|
||||
.withValue("unsubscribedDate", now));
|
||||
expired++;
|
||||
}
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
output.addValue("expiredPendingCount", expired);
|
||||
output.addValue("expiredConsentCount", new CountAction().execute(new CountInput("crm_consent_record")
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("expiryDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)))).getCount());
|
||||
}
|
||||
}
|
||||
+87
-66
@@ -34,9 +34,15 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmails;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailSequence;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceStep;
|
||||
@@ -45,6 +51,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
@@ -53,6 +61,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
@@ -78,6 +87,15 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
|
||||
private static final int MAX_FAILURES = 3;
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the host transport; tests may supply a fake.
|
||||
*******************************************************************************/
|
||||
protected CrmEmailTransport emailTransport() throws QException
|
||||
{
|
||||
return (new CrmEmailTransport(CrmApi.config()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -88,9 +106,9 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withIsHidden(true)
|
||||
.withLabel("Process Sequence Steps")
|
||||
.withIcon(new QIcon().withName("play_circle"))
|
||||
// Scheduling is configured by the host application via ScheduledJob records
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
@@ -109,29 +127,49 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
///////////////////////////////////////////////////////////
|
||||
// query enrollments where status=ACTIVE and due now //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput enrollmentQuery = new QueryAction().execute(
|
||||
new QueryInput(SequenceEnrollment.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))
|
||||
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))));
|
||||
|
||||
int processedCount = 0;
|
||||
|
||||
for(QRecord enrollmentRecord : enrollmentQuery.getRecords())
|
||||
CrmEmailTransport transport = emailTransport();
|
||||
boolean ready = transport.readiness() != CrmReadiness.NOT_CONFIGURED;
|
||||
boolean activeSequences = !new QueryAction().execute(new QueryInput(EmailSequence.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withLimit(1))).getRecords().isEmpty();
|
||||
if(!ready && activeSequences)
|
||||
{
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
|
||||
|
||||
try
|
||||
QAlerts.raise(new AlertSignal("crm.email.unavailable").withDedupKey("transport").withTitle("CRM email is unavailable"));
|
||||
}
|
||||
else if(ready)
|
||||
{
|
||||
QAlerts.resolve("crm.email.unavailable", "transport", "Email transport is ready");
|
||||
}
|
||||
int processedCount = 0;
|
||||
Integer lastId = null;
|
||||
Instant dueThrough = Instant.now();
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))
|
||||
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, dueThrough))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
processEnrollment(enrollment);
|
||||
processedCount++;
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
catch(Exception e)
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput(SequenceEnrollment.TABLE_NAME).withFilter(filter)).getRecords();
|
||||
for(QRecord enrollmentRecord : page)
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// handle step failure //
|
||||
/////////////////////////////////////////////
|
||||
handleFailure(enrollment, e.getMessage());
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
|
||||
try
|
||||
{
|
||||
processEnrollment(enrollment, transport);
|
||||
processedCount++;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
handleFailure(enrollment, e.getMessage());
|
||||
}
|
||||
lastId = enrollment.getId();
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +182,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
** Process a single enrollment: load the next step and execute it.
|
||||
** Respects businessDaysOnly and sendWindow settings on the sequence.
|
||||
***************************************************************************/
|
||||
private void processEnrollment(SequenceEnrollment enrollment) throws QException
|
||||
private void processEnrollment(SequenceEnrollment enrollment, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
Integer nextStepNumber = enrollment.getCurrentStepNumber() + 1;
|
||||
|
||||
@@ -157,6 +195,11 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
EmailSequence sequence = sequenceGet.getRecord() != null
|
||||
? new EmailSequence(sequenceGet.getRecord()) : null;
|
||||
|
||||
if(sequence == null || !Boolean.TRUE.equals(sequence.getIsActive()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// load the contact for timezone //
|
||||
/////////////////////////////////////////////
|
||||
@@ -227,7 +270,27 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
{
|
||||
switch(stepType)
|
||||
{
|
||||
case EMAIL -> executeEmailStep(enrollment, step);
|
||||
case EMAIL ->
|
||||
{
|
||||
var eligibility = new CrmMarketingApi().checkEmailEligibility(enrollment.getContactId());
|
||||
if(!eligibility.eligible())
|
||||
{
|
||||
CrmRecords.update(SequenceEnrollment.TABLE_NAME, new QRecord().withValue("id", enrollment.getId())
|
||||
.withValue("status", CrmEnrollmentStatus.PAUSED.getId()).withValue("lastFailureMessage", eligibility.reason())
|
||||
.withValue("nextStepDate", null));
|
||||
return;
|
||||
}
|
||||
if(transport.readiness() == CrmReadiness.NOT_CONFIGURED)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(step.getEmailTemplateId() == null)
|
||||
{
|
||||
throw (new QException("Sequence email template is required"));
|
||||
}
|
||||
CrmEmails.send(transport, contactGet.getRecord(), step.getEmailTemplateId(), null, null,
|
||||
"seq", enrollment.getId() + ":" + step.getId(), enrollment.getId());
|
||||
}
|
||||
case CALL -> executeCallStep(enrollment, step);
|
||||
case TASK -> executeTaskStep(enrollment, step);
|
||||
case WAIT ->
|
||||
@@ -247,50 +310,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Execute an EMAIL step: verify doNotEmail, create an activity record.
|
||||
***************************************************************************/
|
||||
private void executeEmailStep(SequenceEnrollment enrollment, SequenceStep step) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// check doNotEmail //
|
||||
///////////////////////////////////////////////
|
||||
GetOutput contactGet = new GetAction().execute(
|
||||
new GetInput(Contact.TABLE_NAME).withPrimaryKey(enrollment.getContactId()));
|
||||
|
||||
if(contactGet.getRecord() != null)
|
||||
{
|
||||
Contact contact = new Contact(contactGet.getRecord());
|
||||
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
|
||||
{
|
||||
throw (new QException("Contact has opted out of email"));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// create activity for the email //
|
||||
///////////////////////////////////////////////
|
||||
String subject = "Sequence email: step " + step.getStepNumber();
|
||||
if(step.getEmailTemplateId() != null)
|
||||
{
|
||||
subject += " (template #" + step.getEmailTemplateId() + ")";
|
||||
}
|
||||
|
||||
Integer activityTypeId = lookupActivityTypeByName("Email");
|
||||
|
||||
new InsertAction().execute(
|
||||
new InsertInput(Activity.TABLE_NAME).withRecordEntity(
|
||||
new Activity()
|
||||
.withActivityTypeId(activityTypeId)
|
||||
.withContactId(enrollment.getContactId())
|
||||
.withDealId(enrollment.getDealId())
|
||||
.withSubject(subject)
|
||||
.withOwnerUserId("system")
|
||||
.withIsCompleted(true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Execute a CALL step: create a task activity assigned to the contact owner.
|
||||
***************************************************************************/
|
||||
@@ -514,6 +533,8 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
{
|
||||
updateRecord.withValue("status", CrmEnrollmentStatus.FAILED.getPossibleValueId());
|
||||
updateRecord.withValue("nextStepDate", null);
|
||||
QAlerts.raise(new AlertSignal("crm.sequence.failing").withDedupKey("enrollment:" + enrollment.getId())
|
||||
.withTitle("Sequence enrollment failed three times"));
|
||||
}
|
||||
|
||||
new UpdateAction().execute(
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process to send an email to a contact. Creates an Activity and an
|
||||
** EmailMessage record, respecting doNotEmail flags and optional template
|
||||
** lookups.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailMessage;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailTemplate;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for sending an email to a CRM contact.
|
||||
** Validates doNotEmail, optionally loads a template, creates an Activity
|
||||
** record, and creates a tracked EmailMessage record.
|
||||
*******************************************************************************/
|
||||
public class SendEmailProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_send_email";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Send Email")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("email"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(SendEmailProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("subject", QFieldType.STRING),
|
||||
new QFieldMetaData("bodyHtml", QFieldType.STRING),
|
||||
new QFieldMetaData("bodyText", QFieldType.STRING),
|
||||
new QFieldMetaData("emailTemplateId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("ccAddresses", QFieldType.STRING),
|
||||
new QFieldMetaData("bccAddresses", QFieldType.STRING)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the send-email process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer contactId = input.getValueInteger("contactId");
|
||||
String subject = input.getValueString("subject");
|
||||
String bodyHtml = input.getValueString("bodyHtml");
|
||||
String bodyText = input.getValueString("bodyText");
|
||||
Integer emailTemplateId = input.getValueInteger("emailTemplateId");
|
||||
Integer dealId = input.getValueInteger("dealId");
|
||||
String ccAddresses = input.getValueString("ccAddresses");
|
||||
String bccAddresses = input.getValueString("bccAddresses");
|
||||
String sessionUserId = CrmSessionUtils.getCurrentUserId();
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Step 1: load and validate the contact //
|
||||
///////////////////////////////////////////
|
||||
QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId);
|
||||
if(contactRecord == null)
|
||||
{
|
||||
throw new QException("Contact not found: " + contactId);
|
||||
}
|
||||
|
||||
Contact contact = new Contact(contactRecord);
|
||||
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
|
||||
{
|
||||
throw new QUserFacingException("Cannot send email: contact has doNotEmail set to true.");
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// Step 2: if template provided, load and apply it //
|
||||
//////////////////////////////////////////////////////
|
||||
if(emailTemplateId != null)
|
||||
{
|
||||
QRecord templateRecord = GetAction.execute(EmailTemplate.TABLE_NAME, emailTemplateId);
|
||||
if(templateRecord != null)
|
||||
{
|
||||
EmailTemplate template = new EmailTemplate(templateRecord);
|
||||
if(subject == null)
|
||||
{
|
||||
subject = template.getSubject();
|
||||
}
|
||||
if(bodyHtml == null)
|
||||
{
|
||||
bodyHtml = template.getBodyHtml();
|
||||
}
|
||||
if(bodyText == null)
|
||||
{
|
||||
bodyText = template.getBodyText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
// Step 3: look up Email activity type //
|
||||
/////////////////////////////////////////
|
||||
Integer emailTypeId = lookupEmailActivityTypeId();
|
||||
|
||||
//////////////////////////////////////////
|
||||
// Step 4: create the Activity record //
|
||||
//////////////////////////////////////////
|
||||
Activity activity = new Activity()
|
||||
.withActivityTypeId(emailTypeId)
|
||||
.withSubject(subject)
|
||||
.withContactId(contactId)
|
||||
.withDealId(dealId)
|
||||
.withDirection(CrmDirection.OUTBOUND.getPossibleValueId())
|
||||
.withOwnerUserId(sessionUserId)
|
||||
.withIsCompleted(true);
|
||||
|
||||
InsertOutput activityInsert = new InsertAction().execute(
|
||||
new InsertInput(Activity.TABLE_NAME).withRecordEntity(activity));
|
||||
Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id");
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// Step 5: create the EmailMessage record //
|
||||
//////////////////////////////////////////////
|
||||
String toAddress = contact.getEmail() != null ? contact.getEmail() : "";
|
||||
|
||||
EmailMessage emailMessage = new EmailMessage()
|
||||
.withActivityId(activityId)
|
||||
.withContactId(contactId)
|
||||
.withFromAddress(sessionUserId)
|
||||
.withFromName(sessionUserId)
|
||||
.withToAddresses(toAddress)
|
||||
.withCcAddresses(ccAddresses)
|
||||
.withBccAddresses(bccAddresses)
|
||||
.withSubject(subject != null ? subject : "")
|
||||
.withBodyHtml(bodyHtml)
|
||||
.withBodyText(bodyText)
|
||||
.withDirection(CrmDirection.OUTBOUND.getPossibleValueId())
|
||||
.withStatus(CrmEmailStatus.SENT.getPossibleValueId())
|
||||
.withSentDate(Instant.now())
|
||||
.withIsTracked(true)
|
||||
.withOpenCount(0)
|
||||
.withClickCount(0)
|
||||
.withEmailTemplateId(emailTemplateId);
|
||||
|
||||
InsertOutput emailInsert = new InsertAction().execute(
|
||||
new InsertInput(EmailMessage.TABLE_NAME).withRecordEntity(emailMessage));
|
||||
Integer emailMessageId = emailInsert.getRecords().get(0).getValueInteger("id");
|
||||
|
||||
output.addValue("emailMessageId", emailMessageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Look up the "Email" activity type id. Returns null if not found.
|
||||
***************************************************************************/
|
||||
private Integer lookupEmailActivityTypeId() throws QException
|
||||
{
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(ActivityType.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Email"))));
|
||||
|
||||
if(!queryOutput.getRecords().isEmpty())
|
||||
{
|
||||
return (queryOutput.getRecords().get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled CRM maintenance: crm_sync_email_delivery.
|
||||
*******************************************************************************/
|
||||
public class SyncEmailDeliveryProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_sync_email_delivery";
|
||||
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance instance)
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withLabel("SyncEmailDelivery").withIsHidden(true)
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(SyncEmailDeliveryProcess.class))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the host transport; an absent qbit-email yields empty lookups.
|
||||
*******************************************************************************/
|
||||
protected CrmEmailTransport emailTransport() throws QException
|
||||
{
|
||||
return (new CrmEmailTransport(CrmApi.config()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmEmailTransport transport = emailTransport();
|
||||
Integer lastId = null;
|
||||
int updated = 0;
|
||||
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(filter)).getRecords();
|
||||
for(QRecord message : page)
|
||||
{
|
||||
lastId = message.getValueInteger("id");
|
||||
if(message.getValueInteger("status") != null && List.of(CrmEmailStatus.BOUNCED.getId(), CrmEmailStatus.COMPLAINED.getId(), CrmEmailStatus.FAILED.getId(),
|
||||
CrmEmailStatus.SUPPRESSED.getId()).contains(message.getValueInteger("status")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var delivery = transport.lookup(message.getValueString("idempotencyKey"));
|
||||
if(delivery.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
CrmEmailTransport.Delivery state = delivery.get();
|
||||
QRecord change = new QRecord().withValue("id", lastId).withValue("status", state.status().getId());
|
||||
date(change, "sentDate", state.sentDate());
|
||||
date(change, "deliveredDate", state.deliveredDate());
|
||||
date(change, "firstOpenedDate", state.openedDate());
|
||||
date(change, "firstClickedDate", state.clickedDate());
|
||||
date(change, "bouncedDate", state.bouncedDate());
|
||||
boolean complaint = state.status() == CrmEmailStatus.COMPLAINED;
|
||||
if(complaint || state.status() == CrmEmailStatus.BOUNCED)
|
||||
{
|
||||
Integer contactId = message.getValueInteger("contactId");
|
||||
if(contactId != null)
|
||||
{
|
||||
CrmRecords.update("crm_contact", new QRecord().withValue("id", contactId).withValue("doNotEmail", true));
|
||||
String source = complaint ? "Complaint" : "Hard bounce";
|
||||
// The terminal message checkpoint is written last. A retry must not duplicate consent evidence.
|
||||
String evidence = "Email: " + message.getValueString("idempotencyKey");
|
||||
List<QRecord> recorded = new QueryAction().execute(new QueryInput("crm_consent_record").withFilter(new QQueryFilter(
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId),
|
||||
new QFilterCriteria("source", QCriteriaOperator.EQUALS, source),
|
||||
new QFilterCriteria("consentText", QCriteriaOperator.EQUALS, evidence)))).getRecords();
|
||||
if(recorded.isEmpty())
|
||||
{
|
||||
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
|
||||
.withValue("consentType", CrmConsentType.EMAIL.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
|
||||
.withValue("consentDate", Instant.now()).withValue("source", source).withValue("consentText", evidence));
|
||||
}
|
||||
}
|
||||
change.withValue("isSpamComplaint", complaint);
|
||||
}
|
||||
CrmRecords.update("crm_email_message", change);
|
||||
updated++;
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
output.addValue("updatedCount", updated);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never clear a timestamp when the provider has not exposed it.
|
||||
*******************************************************************************/
|
||||
private void date(QRecord row, String field, Instant value)
|
||||
{
|
||||
if(value != null)
|
||||
{
|
||||
row.setValue(field, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process to track email engagement events (open, click, bounce, spam, reply).
|
||||
** Updates the EmailMessage record counters and flags, and may set doNotEmail
|
||||
** on the linked contact for hard bounces and spam complaints.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailMessage;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for tracking email engagement events.
|
||||
** Handles OPEN, CLICK, BOUNCE, SPAM, and REPLY event types.
|
||||
*******************************************************************************/
|
||||
public class TrackEmailEventProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_track_email_event";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Track Email Event")
|
||||
.withTableName(EmailMessage.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("track_changes"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(TrackEmailEventProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("emailMessageId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("eventType", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("bounceTypeId", QFieldType.INTEGER)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the tracking event process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer emailMessageId = input.getValueInteger("emailMessageId");
|
||||
String eventType = input.getValueString("eventType");
|
||||
Integer bounceTypeId = input.getValueInteger("bounceTypeId");
|
||||
|
||||
//////////////////////////////////////////
|
||||
// Step 1: load the email message //
|
||||
//////////////////////////////////////////
|
||||
QRecord emailRecord = GetAction.execute(EmailMessage.TABLE_NAME, emailMessageId);
|
||||
if(emailRecord == null)
|
||||
{
|
||||
throw new QException("Email message not found: " + emailMessageId);
|
||||
}
|
||||
|
||||
EmailMessage email = new EmailMessage(emailRecord);
|
||||
|
||||
//////////////////////////////////////////
|
||||
// Step 2: handle event type //
|
||||
//////////////////////////////////////////
|
||||
QRecord updateRecord = new QRecord().withValue("id", emailMessageId);
|
||||
|
||||
switch(eventType.toUpperCase())
|
||||
{
|
||||
case "OPEN":
|
||||
{
|
||||
int currentOpenCount = email.getOpenCount() != null ? email.getOpenCount() : 0;
|
||||
updateRecord.setValue("openCount", currentOpenCount + 1);
|
||||
if(email.getFirstOpenedDate() == null)
|
||||
{
|
||||
updateRecord.setValue("firstOpenedDate", Instant.now());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "CLICK":
|
||||
{
|
||||
int currentClickCount = email.getClickCount() != null ? email.getClickCount() : 0;
|
||||
updateRecord.setValue("clickCount", currentClickCount + 1);
|
||||
if(email.getFirstClickedDate() == null)
|
||||
{
|
||||
updateRecord.setValue("firstClickedDate", Instant.now());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "BOUNCE":
|
||||
{
|
||||
updateRecord.setValue("bounceType", bounceTypeId);
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// if hard bounce, set contact doNotEmail to true //
|
||||
/////////////////////////////////////////////////////
|
||||
if(bounceTypeId != null && bounceTypeId.equals(CrmBounceType.HARD.getPossibleValueId()))
|
||||
{
|
||||
setContactDoNotEmail(email);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "SPAM":
|
||||
{
|
||||
updateRecord.setValue("isSpamComplaint", true);
|
||||
setContactDoNotEmail(email);
|
||||
break;
|
||||
}
|
||||
|
||||
case "REPLY":
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// if email is linked to a sequence enrollment, mark REPLIED //
|
||||
//////////////////////////////////////////////////////////////
|
||||
if(email.getSequenceEnrollmentId() != null)
|
||||
{
|
||||
QRecord enrollmentUpdate = new QRecord()
|
||||
.withValue("id", email.getSequenceEnrollmentId())
|
||||
.withValue("status", CrmEnrollmentStatus.REPLIED.getPossibleValueId());
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput("crm_sequence_enrollment").withRecord(enrollmentUpdate));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new QException("Unknown event type: " + eventType);
|
||||
}
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(EmailMessage.TABLE_NAME).withRecord(updateRecord));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Finds the contact linked to an email message (via activity) and sets
|
||||
** doNotEmail = true. Looks up by the toAddresses field.
|
||||
***************************************************************************/
|
||||
private void setContactDoNotEmail(EmailMessage email) throws QException
|
||||
{
|
||||
if(email.getToAddresses() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QueryOutput contactQuery = new QueryAction().execute(
|
||||
new QueryInput(Contact.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("email", QCriteriaOperator.EQUALS, email.getToAddresses()))));
|
||||
|
||||
if(!contactQuery.getRecords().isEmpty())
|
||||
{
|
||||
QRecord contactUpdate = new QRecord()
|
||||
.withValue("id", contactQuery.getRecords().get(0).getValueInteger("id"))
|
||||
.withValue("doNotEmail", true);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Contact.TABLE_NAME).withRecord(contactUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+2
-34
@@ -27,7 +27,6 @@ package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
@@ -41,49 +40,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to unenroll a contact from an email sequence.
|
||||
** Backend operation used by the streamed CRM actions to unenroll a contact from an email sequence.
|
||||
** Sets status to UNENROLLED and records the unenrolled date.
|
||||
*******************************************************************************/
|
||||
public class UnenrollFromSequenceProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class UnenrollFromSequenceProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_unenroll_from_sequence";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Unenroll from Sequence")
|
||||
.withIcon(new QIcon().withName("playlist_remove"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(UnenrollFromSequenceProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("enrollmentId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the unenrollment logic.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)
|
||||
** CRM checks for hosts to register with their health middleware. Run in the
|
||||
** caller's tenant context when one is supplied, else as the core system user;
|
||||
** no check returns personal values.
|
||||
*******************************************************************************/
|
||||
public class CrmHealthChecks
|
||||
{
|
||||
private final CrmEmailTransport transport;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same readiness decision as actual email delivery.
|
||||
*******************************************************************************/
|
||||
public CrmHealthChecks(CrmQBitConfig config)
|
||||
{
|
||||
this(new CrmEmailTransport(config));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow a host transport adapter.
|
||||
*******************************************************************************/
|
||||
public CrmHealthChecks(CrmEmailTransport transport)
|
||||
{
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run all checks without registering tables or starting background workers.
|
||||
*******************************************************************************/
|
||||
public Map<String, HealthCheckResult> run() throws QException
|
||||
{
|
||||
return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore()));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expose individual checks through the RC.6 health SPI. With no caller
|
||||
** session (registration at startup), each check runs as the core system
|
||||
** user: token-based authentication modules (OAuth2, Auth0) reject any other
|
||||
** tokenless session, and the checks report only UP/DEGRADED, never values.
|
||||
*******************************************************************************/
|
||||
public List<HealthIndicator> indicators()
|
||||
{
|
||||
return (indicators(QContext.getQSession()));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply the host's monitoring session explicitly for middleware worker threads.
|
||||
** Its permissions and tenant keys are preserved; this adapter never grants keys.
|
||||
** A null session runs each check as the core system user.
|
||||
*******************************************************************************/
|
||||
public List<HealthIndicator> indicators(QSession session)
|
||||
{
|
||||
return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One independently evaluated middleware indicator.
|
||||
*******************************************************************************/
|
||||
private class Indicator implements HealthIndicator
|
||||
{
|
||||
private final String name;
|
||||
private final QSession session;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select a check.
|
||||
*******************************************************************************/
|
||||
private Indicator(String name, QSession session)
|
||||
{
|
||||
this.name = name;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public HealthCheckResult check(QInstance instance)
|
||||
{
|
||||
CapturedContext original = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, session != null ? session : new QSystemUserSession(instance));
|
||||
return (switch(name)
|
||||
{
|
||||
case "crm.email" -> email();
|
||||
case "crm.sequenceLag" -> sequenceLag();
|
||||
default -> leadScore();
|
||||
});
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unconfigured transport is degraded, not a claim that email can be sent.
|
||||
*******************************************************************************/
|
||||
private HealthCheckResult email() throws QException
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query just the oldest active due enrollment.
|
||||
*******************************************************************************/
|
||||
private HealthCheckResult 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));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No successful score pass, or one more than three hours old, needs attention.
|
||||
*******************************************************************************/
|
||||
private HealthCheckResult 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.privacy;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Erases the CRM's copies of a contact's personal data using tenant-scoped actions.
|
||||
*******************************************************************************/
|
||||
public final class CrmPersonalData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Narrow consent exception: only replacing evidence text with "erased".
|
||||
*******************************************************************************/
|
||||
public enum ErasureFlag implements ActionFlag
|
||||
{
|
||||
REDACT_CONSENT
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmPersonalData()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Erase linked data before the contact so retries still have the original email.
|
||||
*******************************************************************************/
|
||||
public static void erase(Integer contactId) throws QException
|
||||
{
|
||||
QRecord contact = GetAction.execute("crm_contact", contactId);
|
||||
if(contact == null)
|
||||
{
|
||||
throw (new QException("Contact not found: " + contactId));
|
||||
}
|
||||
List<Serializable> emails = new ArrayList<>();
|
||||
for(String field : List.of("email", "secondaryEmail"))
|
||||
{
|
||||
if(contact.getValueString(field) != null)
|
||||
{
|
||||
emails.add(contact.getValueString(field));
|
||||
}
|
||||
}
|
||||
// Two queries avoid OR filters crossing tenant scopes and make retries idempotent.
|
||||
eraseForms(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)));
|
||||
if(!emails.isEmpty())
|
||||
{
|
||||
eraseForms(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.IN, emails)));
|
||||
}
|
||||
for(QRecord row : linked("crm_email_list_member", contactId))
|
||||
{
|
||||
redact("crm_email_list_member", row, "email", "firstName", "confirmationTokenHash", "consentText");
|
||||
}
|
||||
for(QRecord row : linked("crm_email_message", contactId))
|
||||
{
|
||||
redact("crm_email_message", row, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText");
|
||||
}
|
||||
for(QRecord row : linked("crm_activity", contactId))
|
||||
{
|
||||
redact("crm_activity", row, "description", "subject");
|
||||
for(QRecord message : query("crm_email_message", new QQueryFilter(new QFilterCriteria("activityId", QCriteriaOperator.EQUALS, row.getValue("id")))))
|
||||
{
|
||||
redact("crm_email_message", message, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText");
|
||||
}
|
||||
}
|
||||
for(QRecord row : linked("crm_consent_record", contactId))
|
||||
{
|
||||
checkedUpdate("crm_consent_record", new QRecord().withValue("id", row.getValue("id")).withValue("consentText", "erased"));
|
||||
redactAudit("crm_consent_record", row.getValue("id"));
|
||||
}
|
||||
for(QRecord row : linked("crm_sequence_enrollment", contactId))
|
||||
{
|
||||
checkedUpdate("crm_sequence_enrollment", new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()).withValue("nextStepDate", null));
|
||||
redactAudit("crm_sequence_enrollment", row.getValue("id"));
|
||||
}
|
||||
for(QRecord row : query("crm_external_mapping", new QQueryFilter(
|
||||
new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getId()),
|
||||
new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId))))
|
||||
{
|
||||
var deleted = new DeleteAction().execute(new DeleteInput("crm_external_mapping").withPrimaryKey(row.getValue("id")).withOmitDmlAudit(true));
|
||||
if(deleted.getDeletedRecordCount() != 1)
|
||||
{
|
||||
throw (new QException("CRM erase could not delete an external mapping"));
|
||||
}
|
||||
redactAudit("crm_external_mapping", row.getValue("id"));
|
||||
}
|
||||
redact("crm_contact", contact, "firstName", "lastName", "phone", "mobilePhone", "secondaryEmail", "addressLine1", "addressLine2",
|
||||
"city", "state", "postalCode", "country", "timezone", "jobTitle", "department", "website", "linkedinUrl", "description");
|
||||
checkedUpdate("crm_contact", new QRecord().withValue("id", contactId).withValue("email", "anonymized-" + contactId + "@removed.invalid")
|
||||
.withValue("doNotEmail", true).withValue("doNotCall", true));
|
||||
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
|
||||
.withValue("consentType", CrmConsentType.DATA_PROCESSING.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
|
||||
.withValue("consentText", "Erased on " + LocalDate.now(ZoneOffset.UTC)).withValue("source", "GDPR erasure").withValue("consentDate", Instant.now()));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Erase both direct and email-matched web leads.
|
||||
*******************************************************************************/
|
||||
private static void eraseForms(QQueryFilter filter) throws QException
|
||||
{
|
||||
for(QRecord row : query("crm_form_submission", filter))
|
||||
{
|
||||
redact("crm_form_submission", row, "firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collect rows through core record locks, never through a backend bypass.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> linked(String table, Integer contactId) throws QException
|
||||
{
|
||||
return (query(table, new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page queries to avoid the backend's default result limit silently omitting PII.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> result = new ArrayList<>();
|
||||
Serializable after = null;
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter page = filter.clone().withOrderBy(new QFilterOrderBy("id")).withLimit(250);
|
||||
if(after != null)
|
||||
{
|
||||
page.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after));
|
||||
}
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(table).withFilter(page)).getRecords();
|
||||
result.addAll(rows);
|
||||
if(rows.size() < 250)
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
after = rows.getLast().getValue("id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep required fields nonempty; never audit the original values during erasure.
|
||||
*******************************************************************************/
|
||||
private static void redact(String table, QRecord row, String... fields) throws QException
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id"));
|
||||
for(String field : fields)
|
||||
{
|
||||
boolean required = Boolean.TRUE.equals(QContext.getQInstance().getTable(table).getField(field).getIsRequired());
|
||||
update.withValue(field, required || field.equals("firstName") || field.equals("lastName") ? "[ANONYMIZED]" : null);
|
||||
}
|
||||
checkedUpdate(table, update);
|
||||
redactAudit(table, row.getValue("id"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failed write must fail erasure; never report a partially erased contact as done.
|
||||
*******************************************************************************/
|
||||
private static void checkedUpdate(String table, QRecord update) throws QException
|
||||
{
|
||||
var output = new UpdateAction().execute(new UpdateInput(table).withRecord(update).withOmitDmlAudit(true).withFlag(ErasureFlag.REDACT_CONSENT));
|
||||
if(output.getRecords().isEmpty() || output.getRecords().stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QException("CRM erase could not redact " + table));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 core audits resolve table ids through auditTable. Redact both values and
|
||||
** human-readable messages, which can embed old record labels and personal values.
|
||||
*******************************************************************************/
|
||||
private static void redactAudit(String table, Serializable recordId) throws QException
|
||||
{
|
||||
if(QContext.getQInstance().getTable("auditTable") == null || QContext.getQInstance().getTable("audit") == null
|
||||
|| QContext.getQInstance().getTable("auditDetail") == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(QRecord auditTable : query("auditTable", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, table))))
|
||||
{
|
||||
for(QRecord audit : query("audit", new QQueryFilter(
|
||||
new QFilterCriteria("auditTableId", QCriteriaOperator.EQUALS, auditTable.getValue("id")),
|
||||
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, recordId))))
|
||||
{
|
||||
for(QRecord detail : query("auditDetail", new QQueryFilter(new QFilterCriteria("auditId", QCriteriaOperator.EQUALS, audit.getValue("id")))))
|
||||
{
|
||||
checkedUpdate("auditDetail", new QRecord().withValue("id", detail.getValue("id"))
|
||||
.withValue("oldValue", "erased").withValue("newValue", "erased").withValue("message", "erased"));
|
||||
}
|
||||
checkedUpdate("audit", new QRecord().withValue("id", audit.getValue("id")).withValue("message", "Personal data erased"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.scoring;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply assignment rules to new contacts and deals without replacing explicit owners.
|
||||
*******************************************************************************/
|
||||
public class AutoAssignCustomizer extends AbstractPostInsertCustomizer
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records) throws QException
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(row.getErrors() != null && !row.getErrors().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String owner = row.getValueString("ownerUserId");
|
||||
if(owner == null || "system".equals(owner))
|
||||
{
|
||||
CrmAutoAssign.Result result = CrmAutoAssign.assign("crm_contact".equals(insertInput.getTableName())
|
||||
? CrmAssignableEntity.CONTACT : CrmAssignableEntity.DEAL, row.getValueInteger("id"));
|
||||
if("ASSIGNED".equals(result.action()))
|
||||
{
|
||||
row.setValue("ownerUserId", result.assignedUserId());
|
||||
QAlerts.notify(new NotificationSignal("CRM lead assigned").withSourceName("crm.lead.assigned")
|
||||
.withSourceKey(insertInput.getTableName() + ":" + row.getValue("id"))
|
||||
.withSourceTableName(insertInput.getTableName()).withSourceRecordId(row.getValueString("id"))
|
||||
.withAudience(NotificationAudience.user(result.assignedUserId())), insertInput.getTransaction());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.scoring;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small persistent maintenance checkpoints, including the last full score pass.
|
||||
*******************************************************************************/
|
||||
public class JobState implements MetaDataProducerInterface<QTableMetaData>
|
||||
{
|
||||
public static final String TABLE_NAME = "crm_job_state";
|
||||
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance instance)
|
||||
{
|
||||
return (new QTableMetaData().withName(TABLE_NAME).withLabel("Job State").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true).withMaxLength(250))
|
||||
.withField(new QFieldMetaData("lastSuccess", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("lastFullPass", QFieldType.DATE_TIME))
|
||||
.withUniqueKey(new UniqueKey("name")));
|
||||
}
|
||||
}
|
||||
@@ -26,22 +26,12 @@
|
||||
package com.kingsrook.qbits.crm.scoring.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.scoring.CrmAutoAssign;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -49,35 +39,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
** based on configured assignment rules. Evaluates rules in sortOrder; first
|
||||
** match wins. Supports SPECIFIC_USER, ROUND_ROBIN, and LEAST_ACTIVE strategies.
|
||||
*******************************************************************************/
|
||||
public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class AutoAssignProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_auto_assign";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Auto Assign")
|
||||
.withIcon(new QIcon().withName("assignment_ind"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(AutoAssignProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("recordId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the assignment logic.
|
||||
*******************************************************************************/
|
||||
|
||||
+145
-70
@@ -26,22 +26,29 @@
|
||||
package com.kingsrook.qbits.crm.scoring.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
|
||||
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qbits.crm.scoring.model.LeadScoreRule;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -80,9 +87,9 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withIsHidden(true)
|
||||
.withLabel("Calculate Lead Scores")
|
||||
.withIcon(new QIcon().withName("score"))
|
||||
// Scheduling is configured by the host application via ScheduledJob records
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
@@ -98,6 +105,14 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Instant startedAt = Instant.now();
|
||||
List<QRecord> states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords();
|
||||
QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst();
|
||||
Instant lastSuccess = state.getValueInstant("lastSuccess");
|
||||
Instant lastFullPass = state.getValueInstant("lastFullPass");
|
||||
boolean full = Boolean.TRUE.equals(input.getValueBoolean("fullRecalc")) || lastSuccess == null || lastFullPass == null
|
||||
|| !lastFullPass.isAfter(startedAt.minus(24, ChronoUnit.HOURS));
|
||||
///////////////////////////////////////////////
|
||||
// load all active rules ordered by sort //
|
||||
///////////////////////////////////////////////
|
||||
@@ -114,7 +129,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
///////////////////////////////////////////////////////////////
|
||||
// paginate through all contacts to avoid OOM on large CRMs //
|
||||
///////////////////////////////////////////////////////////////
|
||||
int pageSize = 500;
|
||||
int pageSize = 200;
|
||||
int updatedCount = 0;
|
||||
int totalContacts = 0;
|
||||
Integer lastId = null;
|
||||
@@ -130,6 +145,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
|
||||
if(!full)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("modifyDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, lastSuccess));
|
||||
}
|
||||
QueryOutput contactsQuery = new QueryAction().execute(
|
||||
new QueryInput(Contact.TABLE_NAME).withFilter(filter));
|
||||
|
||||
@@ -138,41 +157,20 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
break;
|
||||
}
|
||||
|
||||
Map<Integer, QRecord> companies = loadCompanies(contactsQuery.getRecords());
|
||||
List<QRecord> changes = new ArrayList<>();
|
||||
for(QRecord contactRecord : contactsQuery.getRecords())
|
||||
{
|
||||
Contact contact = new Contact(contactRecord);
|
||||
totalContacts++;
|
||||
int newScore = 0;
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// evaluate each rule against the contact //
|
||||
///////////////////////////////////////////////
|
||||
for(LeadScoreRule rule : rules)
|
||||
QRecord change = calculateChange(contactRecord, rules, companies);
|
||||
if(change != null)
|
||||
{
|
||||
if(evaluateRule(rule, contactRecord))
|
||||
{
|
||||
newScore += rule.getScoreAdjustment();
|
||||
}
|
||||
changes.add(change);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// update only if score changed //
|
||||
///////////////////////////////////////////////
|
||||
Integer currentScore = contact.getLeadScore() != null ? contact.getLeadScore() : 0;
|
||||
if(!Objects.equals(currentScore, newScore))
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", contact.getId())
|
||||
.withValue("leadScore", newScore);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
lastId = contact.getId();
|
||||
lastId = contactRecord.getValueInteger("id");
|
||||
}
|
||||
writeScores(changes);
|
||||
updatedCount += changes.size();
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// if page was not full, we are done //
|
||||
@@ -183,6 +181,21 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
}
|
||||
}
|
||||
|
||||
CrmDealNotifications.checkRotting();
|
||||
state.withValue("lastSuccess", startedAt);
|
||||
if(full)
|
||||
{
|
||||
state.withValue("lastFullPass", startedAt);
|
||||
}
|
||||
if(states.isEmpty())
|
||||
{
|
||||
CrmRecords.insert(JobState.TABLE_NAME, state);
|
||||
}
|
||||
else
|
||||
{
|
||||
CrmRecords.update(JobState.TABLE_NAME, state);
|
||||
}
|
||||
output.addValue("fullRecalc", full);
|
||||
output.addValue("updatedCount", updatedCount);
|
||||
output.addValue("totalContacts", totalContacts);
|
||||
output.addValue("totalRules", rules.size());
|
||||
@@ -190,11 +203,63 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recalculate only the selected contact using the same rules as the job.
|
||||
*******************************************************************************/
|
||||
public void recalculate(QRecord contact) throws QException
|
||||
{
|
||||
List<LeadScoreRule> rules = new QueryAction().execute(new QueryInput(LeadScoreRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")))).getRecords().stream().map(LeadScoreRule::new).toList();
|
||||
QRecord change = calculateChange(contact, rules, loadCompanies(List.of(contact)));
|
||||
writeScores(change == null ? List.of() : List.of(change));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compute a score update only when the result changed.
|
||||
*******************************************************************************/
|
||||
private QRecord calculateChange(QRecord contact, List<LeadScoreRule> rules, Map<Integer, QRecord> companies) throws QException
|
||||
{
|
||||
int score = 0;
|
||||
for(LeadScoreRule rule : rules)
|
||||
{
|
||||
if(evaluateRule(rule, contact, companies))
|
||||
{
|
||||
score += rule.getScoreAdjustment();
|
||||
}
|
||||
}
|
||||
Integer current = contact.getValueInteger("leadScore");
|
||||
if(Objects.equals(current == null ? 0 : current, score))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Batch score writes so core audit label resolution also operates per page.
|
||||
*******************************************************************************/
|
||||
private void writeScores(List<QRecord> changes) throws QException
|
||||
{
|
||||
if(changes.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
UpdateInput update = new UpdateInput(Contact.TABLE_NAME).withOmitModifyDateUpdate(true);
|
||||
update.setRecords(changes);
|
||||
var updated = new UpdateAction().execute(update).getRecords();
|
||||
if(updated.isEmpty() || updated.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QException("Lead score update was rejected"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Evaluate a single rule against a contact record. Supports dot-notation
|
||||
** field paths for related entities (e.g., "company.industry").
|
||||
***************************************************************************/
|
||||
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord)
|
||||
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord, Map<Integer, QRecord> companies)
|
||||
{
|
||||
CrmScoreOperator operator = CrmScoreOperator.getById(rule.getOperator());
|
||||
if(operator == null)
|
||||
@@ -203,7 +268,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
}
|
||||
|
||||
String fieldPath = rule.getFieldPath();
|
||||
String actualValue = resolveFieldValue(fieldPath, contactRecord);
|
||||
String actualValue = resolveFieldValue(fieldPath, contactRecord, companies);
|
||||
String expectedValue = rule.getFieldValue();
|
||||
|
||||
switch(operator)
|
||||
@@ -223,6 +288,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
case LT:
|
||||
return (compareNumeric(actualValue, expectedValue) < 0);
|
||||
|
||||
case DAYS_AGO_GT:
|
||||
case DAYS_AGO_LT:
|
||||
return (compareAge(actualValue, expectedValue, operator));
|
||||
|
||||
case IS_SET:
|
||||
return (StringUtils.hasContent(actualValue));
|
||||
|
||||
@@ -262,48 +331,54 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
** for related entity fields (e.g., "company.industry" loads the contact's
|
||||
** company record and returns its "industry" field).
|
||||
***************************************************************************/
|
||||
private String resolveFieldValue(String fieldPath, QRecord contactRecord)
|
||||
private String resolveFieldValue(String fieldPath, QRecord contactRecord, Map<Integer, QRecord> companies)
|
||||
{
|
||||
if(fieldPath == null || !fieldPath.contains("."))
|
||||
{
|
||||
return (contactRecord.getValueString(fieldPath));
|
||||
}
|
||||
|
||||
String[] parts = fieldPath.split("\\.", 2);
|
||||
String prefix = parts[0];
|
||||
String fieldName = parts[1];
|
||||
|
||||
try
|
||||
if("company".equalsIgnoreCase(parts[0]))
|
||||
{
|
||||
if("company".equalsIgnoreCase(prefix))
|
||||
{
|
||||
Integer companyId = contactRecord.getValueInteger("companyId");
|
||||
if(companyId == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
GetOutput companyOutput = new GetAction().execute(
|
||||
new GetInput(Company.TABLE_NAME).withPrimaryKey(companyId));
|
||||
|
||||
if(companyOutput.getRecord() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (companyOutput.getRecord().getValueString(fieldName));
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + prefix);
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error resolving dot-notation field [" + fieldPath + "] for contact", e);
|
||||
return (null);
|
||||
QRecord company = companies.get(contactRecord.getValueInteger("companyId"));
|
||||
return (company == null ? null : company.getValueString(parts[1]));
|
||||
}
|
||||
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + parts[0]);
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One company query per page, shared by every rule and contact in that page.
|
||||
*******************************************************************************/
|
||||
private Map<Integer, QRecord> loadCompanies(List<QRecord> contacts) throws QException
|
||||
{
|
||||
List<Integer> ids = contacts.stream().map(row -> row.getValueInteger("companyId")).filter(Objects::nonNull).distinct().toList();
|
||||
Map<Integer, QRecord> companies = new HashMap<>();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord company : new QueryAction().execute(new QueryInput(Company.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)))).getRecords())
|
||||
{
|
||||
companies.put(company.getValueInteger("id"), company);
|
||||
}
|
||||
}
|
||||
return (companies);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare instants or UTC calendar dates with the strict N-days-ago boundary.
|
||||
*******************************************************************************/
|
||||
private boolean compareAge(String actual, String expected, CrmScoreOperator operator)
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant date = actual.length() == 10 ? LocalDate.parse(actual).atStartOfDay(ZoneOffset.UTC).toInstant() : Instant.parse(actual);
|
||||
Instant threshold = Instant.now().minus(Long.parseLong(expected), ChronoUnit.DAYS);
|
||||
return (operator == CrmScoreOperator.DAYS_AGO_GT ? date.isBefore(threshold) : date.isAfter(threshold));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.security;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.CrmAppMetaData;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM field classification and optional tenant record locks.
|
||||
*******************************************************************************/
|
||||
public final class CrmDataPolicy
|
||||
{
|
||||
private static final Map<String, List<String>> PERSONAL_FIELDS = Map.of(
|
||||
"crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1",
|
||||
"addressLine2", "city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"),
|
||||
"crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"),
|
||||
"crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"),
|
||||
"crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"),
|
||||
"crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"),
|
||||
"crm_consent_record", List.of("consentText"),
|
||||
"crm_activity", List.of("description"));
|
||||
|
||||
private static final Map<String, String> PARENTS = Map.ofEntries(
|
||||
Map.entry("crm_pipeline_stage", "crm_pipeline"),
|
||||
Map.entry("crm_assignment_rule_member", "crm_assignment_rule"),
|
||||
Map.entry("crm_consent_record", "crm_contact"),
|
||||
Map.entry("crm_email_list_member", "crm_contact"),
|
||||
Map.entry("crm_email_message", "crm_contact"),
|
||||
Map.entry("crm_sequence_enrollment", "crm_contact"),
|
||||
Map.entry("crm_sequence_step", "crm_email_sequence"),
|
||||
Map.entry("crm_activity_participant", "crm_activity"),
|
||||
Map.entry("crm_deal_contact", "crm_deal"),
|
||||
Map.entry("crm_deal_product", "crm_deal"),
|
||||
Map.entry("crm_deal_stage_history", "crm_deal"));
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmDataPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply after joins have their final names, before backend name inference.
|
||||
*******************************************************************************/
|
||||
public static void apply(MetaDataProducerMultiOutput output, QInstance instance, CrmQBitConfig config)
|
||||
{
|
||||
String keyType = config.getTenantSecurityKeyType();
|
||||
if(keyType != null && instance.getSecurityKeyType(keyType) == null)
|
||||
{
|
||||
output.add(new QSecurityKeyType().withName(keyType).withAllAccessKeyName(config.getTenantAllAccessKeyName()));
|
||||
}
|
||||
for(QTableMetaData table : output.getEach(QTableMetaData.class))
|
||||
{
|
||||
for(String name : PERSONAL_FIELDS.getOrDefault(table.getName(), List.of()))
|
||||
{
|
||||
QFieldMetaData field = table.getField(name);
|
||||
if(field == null)
|
||||
{
|
||||
throw (new IllegalStateException("Unknown CRM PII field: " + table.getName() + "." + name));
|
||||
}
|
||||
AiFieldMetaData ai = AiFieldMetaData.of(field);
|
||||
field.withSupplementalMetaData((ai == null ? new AiFieldMetaData() : ai).withClassification(DataClassification.PII));
|
||||
}
|
||||
if(keyType == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
RecordSecurityLock lock = new RecordSecurityLock().withSecurityKeyType(keyType);
|
||||
String parent = PARENTS.get(table.getName());
|
||||
if(parent == null)
|
||||
{
|
||||
// Polymorphic external mappings and internal job checkpoints need a direct key too.
|
||||
table.withField(new QFieldMetaData("tenantKey", QFieldType.STRING).withMaxLength(250));
|
||||
if(table.getSections() != null && !table.getSections().isEmpty())
|
||||
{
|
||||
table.withSection(new QFieldSection("crm_tenancy", "Tenancy", new QIcon("lock"), Tier.T3, List.of("tenantKey")));
|
||||
}
|
||||
lock.withFieldName("tenantKey");
|
||||
}
|
||||
else
|
||||
{
|
||||
QJoinMetaData join = output.getEach(QJoinMetaData.class).stream()
|
||||
.filter(candidate -> parent.equals(candidate.getLeftTable()) && table.getName().equals(candidate.getRightTable()))
|
||||
.findFirst().orElseThrow(() -> new IllegalStateException("Missing tenant parent join for " + table.getName()));
|
||||
lock.withFieldName(parent + ".tenantKey").withJoinNameChain(List.of(join.getName()));
|
||||
}
|
||||
table.withRecordSecurityLock(lock);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Main and settings tables exposed through the host API.
|
||||
*******************************************************************************/
|
||||
public static boolean isPublishedTable(String name)
|
||||
{
|
||||
return (CrmAppMetaData.MAIN_TABLES.contains(name) || CrmAppMetaData.SETTINGS_TABLES.contains(name));
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public final class CrmPermissions
|
||||
public static final List<String> ALL = List.of(APP_ACCESS, READ, WRITE, MANAGE, MANAGE_READ, MANAGE_WRITE, ADMIN, ADMIN_READ, ADMIN_WRITE);
|
||||
|
||||
private static final Set<String> SETTINGS_TABLES = Set.of(
|
||||
"crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
|
||||
"crm_job_state", "crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
|
||||
"crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_lifecycle_stage",
|
||||
"crm_lead_source", "crm_industry", "crm_activity_type", "crm_activity_outcome",
|
||||
"crm_contact_role", "crm_win_loss_reason");
|
||||
@@ -57,7 +57,8 @@ public final class CrmPermissions
|
||||
private static final Set<String> MANAGEMENT_PROCESSES = Set.of(
|
||||
"crm_merge_contacts", "crm_merge_companies", "crm_bulk_assign_owner",
|
||||
"crm_subscribe_to_list", "crm_unsubscribe_from_list", "crm_enroll_in_sequence",
|
||||
"crm_unenroll_from_sequence", "crm_process_sequence_steps");
|
||||
"crm_unenroll_from_sequence", "crm_process_sequence_steps", "crm_assign_company_owner",
|
||||
"crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence", "crm_sync_email_delivery", "crm_expire_consents", "crm_calculate_lead_scores");
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.security;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM relationship invariants, enforced for inserts and partial updates.
|
||||
*******************************************************************************/
|
||||
public class CrmRelationshipValidator implements TableCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview,
|
||||
Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
Map<Object, QRecord> oldById = new HashMap<>();
|
||||
oldRecords.orElse(List.of()).forEach(row -> oldById.put(row.getValue("id"), row));
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord effective = new QRecord();
|
||||
QRecord old = oldById.get(record.getValue("id"));
|
||||
if(old != null)
|
||||
{
|
||||
effective.getValues().putAll(old.getValues());
|
||||
}
|
||||
effective.getValues().putAll(record.getValues());
|
||||
if(effective.getValues().containsKey("pipelineStageId"))
|
||||
{
|
||||
validate(record, effective, "pipelineStageId", "pipelineId", "crm_pipeline_stage");
|
||||
}
|
||||
if(effective.getValues().containsKey("outcomeId"))
|
||||
{
|
||||
validate(record, effective, "outcomeId", "activityTypeId", "crm_activity_outcome");
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing optional choices are allowed; populated choices must belong to the parent.
|
||||
*******************************************************************************/
|
||||
private void validate(QRecord record, QRecord effective, String childField, String parentField, String source) throws QException
|
||||
{
|
||||
if(effective.getValue(childField) == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord choice = GetAction.execute(source, effective.getValue(childField));
|
||||
if(choice == null || effective.getValue(parentField) == null
|
||||
|| !Objects.equals(choice.getValueInteger(parentField), effective.getValueInteger(parentField)))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Choose a value belonging to the selected " + parentField + "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
@@ -489,8 +531,8 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
static TableData table(List<Map<String, Object>> rows, String first, String second, String third)
|
||||
{
|
||||
return (new TableData(null, List.of(new TableData.Column("html", first, "first", "2fr", "left"),
|
||||
new TableData.Column("html", second, "second", "1fr", "left"), new TableData.Column("html", third, "third", "1fr", "left")), rows)
|
||||
return (new TableData(null, List.of(new TableData.Column("html", first, "first", null, "left"),
|
||||
new TableData.Column("html", second, "second", null, "left"), new TableData.Column("html", third, "third", null, "left")), rows)
|
||||
.withRowsPerPage(10).withHidePaginationDropdown(true));
|
||||
}
|
||||
}
|
||||
@@ -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,128 @@
|
||||
[
|
||||
{
|
||||
"label": "CRM Contacts - Generic CSV",
|
||||
"tableName": "crm_contact",
|
||||
"mapping": {
|
||||
"version": "1",
|
||||
"layout": "FLAT",
|
||||
"hasHeaderRow": true,
|
||||
"isBulkEdit": false,
|
||||
"fieldList": [
|
||||
{
|
||||
"fieldName": "firstName",
|
||||
"headerName": "First Name",
|
||||
"columnIndex": 0
|
||||
},
|
||||
{
|
||||
"fieldName": "lastName",
|
||||
"headerName": "Last Name",
|
||||
"columnIndex": 1
|
||||
},
|
||||
{
|
||||
"fieldName": "email",
|
||||
"headerName": "Email",
|
||||
"columnIndex": 2
|
||||
},
|
||||
{
|
||||
"fieldName": "phone",
|
||||
"headerName": "Phone",
|
||||
"columnIndex": 3
|
||||
},
|
||||
{
|
||||
"fieldName": "companyId",
|
||||
"headerName": "Company",
|
||||
"columnIndex": 4
|
||||
},
|
||||
{
|
||||
"fieldName": "jobTitle",
|
||||
"headerName": "Job Title",
|
||||
"columnIndex": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "CRM Companies - Generic CSV",
|
||||
"tableName": "crm_company",
|
||||
"mapping": {
|
||||
"version": "1",
|
||||
"layout": "FLAT",
|
||||
"hasHeaderRow": true,
|
||||
"isBulkEdit": false,
|
||||
"fieldList": [
|
||||
{
|
||||
"fieldName": "name",
|
||||
"headerName": "Company",
|
||||
"columnIndex": 0
|
||||
},
|
||||
{
|
||||
"fieldName": "domain",
|
||||
"headerName": "Domain",
|
||||
"columnIndex": 1
|
||||
},
|
||||
{
|
||||
"fieldName": "phone",
|
||||
"headerName": "Phone",
|
||||
"columnIndex": 2
|
||||
},
|
||||
{
|
||||
"fieldName": "addressLine1",
|
||||
"headerName": "Address",
|
||||
"columnIndex": 3
|
||||
},
|
||||
{
|
||||
"fieldName": "city",
|
||||
"headerName": "City",
|
||||
"columnIndex": 4
|
||||
},
|
||||
{
|
||||
"fieldName": "postalCode",
|
||||
"headerName": "Postal Code",
|
||||
"columnIndex": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "CRM Deals - Generic CSV",
|
||||
"tableName": "crm_deal",
|
||||
"mapping": {
|
||||
"version": "1",
|
||||
"layout": "FLAT",
|
||||
"hasHeaderRow": true,
|
||||
"isBulkEdit": false,
|
||||
"fieldList": [
|
||||
{
|
||||
"fieldName": "name",
|
||||
"headerName": "Deal",
|
||||
"columnIndex": 0
|
||||
},
|
||||
{
|
||||
"fieldName": "companyId",
|
||||
"headerName": "Company",
|
||||
"columnIndex": 1
|
||||
},
|
||||
{
|
||||
"fieldName": "pipelineId",
|
||||
"headerName": "Pipeline",
|
||||
"columnIndex": 2
|
||||
},
|
||||
{
|
||||
"fieldName": "pipelineStageId",
|
||||
"headerName": "Stage",
|
||||
"columnIndex": 3
|
||||
},
|
||||
{
|
||||
"fieldName": "amount",
|
||||
"headerName": "Amount",
|
||||
"columnIndex": 4
|
||||
},
|
||||
{
|
||||
"fieldName": "currencyCode",
|
||||
"headerName": "Currency",
|
||||
"columnIndex": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,50 @@
|
||||
--liquibase formatted sql
|
||||
-- Optional: include after crm.changelog.sql ONLY for tenant-enabled installations.
|
||||
-- Run with context crm-tenancy. Omit this file entirely when tenancy is off.
|
||||
--changeset qbit-crm:020-crm-tenancy context:@crm-tenancy
|
||||
ALTER TABLE crm_contact ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_contact_tenant ON crm_contact (tenant_key);
|
||||
ALTER TABLE crm_company ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_company_tenant ON crm_company (tenant_key);
|
||||
ALTER TABLE crm_deal ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_deal_tenant ON crm_deal (tenant_key);
|
||||
ALTER TABLE crm_activity ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_activity_tenant ON crm_activity (tenant_key);
|
||||
ALTER TABLE crm_form_submission ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_form_submission_tenant ON crm_form_submission (tenant_key);
|
||||
ALTER TABLE crm_email_list ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_email_list_tenant ON crm_email_list (tenant_key);
|
||||
ALTER TABLE crm_pipeline ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_pipeline_tenant ON crm_pipeline (tenant_key);
|
||||
ALTER TABLE crm_product ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_product_tenant ON crm_product (tenant_key);
|
||||
ALTER TABLE crm_email_template ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_email_template_tenant ON crm_email_template (tenant_key);
|
||||
ALTER TABLE crm_email_sequence ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_email_sequence_tenant ON crm_email_sequence (tenant_key);
|
||||
ALTER TABLE crm_external_mapping ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_external_mapping_tenant ON crm_external_mapping (tenant_key);
|
||||
ALTER TABLE crm_lead_score_rule ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_lead_score_rule_tenant ON crm_lead_score_rule (tenant_key);
|
||||
ALTER TABLE crm_assignment_rule ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_assignment_rule_tenant ON crm_assignment_rule (tenant_key);
|
||||
ALTER TABLE crm_sales_goal ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_sales_goal_tenant ON crm_sales_goal (tenant_key);
|
||||
ALTER TABLE crm_currency ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_currency_tenant ON crm_currency (tenant_key);
|
||||
ALTER TABLE crm_lifecycle_stage ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_lifecycle_stage_tenant ON crm_lifecycle_stage (tenant_key);
|
||||
ALTER TABLE crm_lead_source ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_lead_source_tenant ON crm_lead_source (tenant_key);
|
||||
ALTER TABLE crm_industry ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_industry_tenant ON crm_industry (tenant_key);
|
||||
ALTER TABLE crm_activity_type ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_activity_type_tenant ON crm_activity_type (tenant_key);
|
||||
ALTER TABLE crm_activity_outcome ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_activity_outcome_tenant ON crm_activity_outcome (tenant_key);
|
||||
ALTER TABLE crm_contact_role ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_contact_role_tenant ON crm_contact_role (tenant_key);
|
||||
ALTER TABLE crm_win_loss_reason ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_win_loss_reason_tenant ON crm_win_loss_reason (tenant_key);
|
||||
ALTER TABLE crm_job_state ADD COLUMN tenant_key VARCHAR(250);
|
||||
CREATE INDEX idx_crm_job_state_tenant ON crm_job_state (tenant_key);
|
||||
@@ -1,12 +1,13 @@
|
||||
--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 0.2.0 P1 PostgreSQL reference schema (33 tables).
|
||||
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (34 tables).
|
||||
-- QQQ table names equal SQL names; Java field names map to snake_case columns.
|
||||
-- Unique constraints match QQQ metadata. Relationship columns are indexed;
|
||||
-- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns
|
||||
-- validation and deletion policy, including polymorphic and optional references).
|
||||
-- Optional host-configured securityFields require matching host-managed columns.
|
||||
-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy.
|
||||
-- With tenancy off, do not include it; this base schema has no tenant_key columns.
|
||||
|
||||
--changeset qbit-crm:020-crm_activity
|
||||
CREATE TABLE IF NOT EXISTS crm_activity (
|
||||
@@ -171,27 +172,27 @@ CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_recor
|
||||
|
||||
--changeset qbit-crm:020-crm_contact
|
||||
CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
first_touch_utm_source VARCHAR(250),
|
||||
first_touch_utm_medium VARCHAR(250),
|
||||
first_touch_utm_campaign VARCHAR(250),
|
||||
first_touch_utm_content VARCHAR(250),
|
||||
first_touch_utm_term VARCHAR(250),
|
||||
first_touch_utm_source VARCHAR(500),
|
||||
first_touch_utm_medium VARCHAR(500),
|
||||
first_touch_utm_campaign VARCHAR(500),
|
||||
first_touch_utm_content VARCHAR(500),
|
||||
first_touch_utm_term VARCHAR(500),
|
||||
first_touch_landing_page VARCHAR(1000),
|
||||
first_touch_referrer VARCHAR(1000),
|
||||
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_touch_utm_source VARCHAR(250),
|
||||
last_touch_utm_medium VARCHAR(250),
|
||||
last_touch_utm_campaign VARCHAR(250),
|
||||
last_touch_utm_content VARCHAR(250),
|
||||
last_touch_utm_term VARCHAR(250),
|
||||
last_touch_utm_source VARCHAR(500),
|
||||
last_touch_utm_medium VARCHAR(500),
|
||||
last_touch_utm_campaign VARCHAR(500),
|
||||
last_touch_utm_content VARCHAR(500),
|
||||
last_touch_utm_term VARCHAR(500),
|
||||
last_touch_landing_page VARCHAR(1000),
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(250),
|
||||
secondary_email VARCHAR(250),
|
||||
email VARCHAR(320),
|
||||
secondary_email VARCHAR(320),
|
||||
phone VARCHAR(50),
|
||||
mobile_phone VARCHAR(50),
|
||||
job_title VARCHAR(150),
|
||||
@@ -199,7 +200,7 @@ CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
company_id INTEGER,
|
||||
lifecycle_stage_id INTEGER,
|
||||
lead_source_id INTEGER,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
address_line1 VARCHAR(250),
|
||||
address_line2 VARCHAR(250),
|
||||
city VARCHAR(100),
|
||||
@@ -265,7 +266,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
|
||||
weighted_amount DECIMAL(20,6),
|
||||
company_id INTEGER,
|
||||
primary_contact_id INTEGER,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
lead_source_id INTEGER,
|
||||
expected_close_date DATE,
|
||||
actual_close_date DATE,
|
||||
@@ -364,7 +365,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email_list_id INTEGER NOT NULL,
|
||||
contact_id INTEGER,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
status INTEGER DEFAULT 1 NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
@@ -383,9 +384,13 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
|
||||
--changeset qbit-crm:020-crm_email_message
|
||||
CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
idempotency_key VARCHAR(250) UNIQUE,
|
||||
email_message_id VARCHAR(100),
|
||||
delivered_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
bounced_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
activity_id INTEGER,
|
||||
contact_id INTEGER,
|
||||
from_address VARCHAR(250) NOT NULL,
|
||||
from_address VARCHAR(250),
|
||||
from_name VARCHAR(100),
|
||||
to_addresses TEXT NOT NULL,
|
||||
cc_addresses TEXT,
|
||||
@@ -411,6 +416,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_message_id ON crm_email_message (email_message_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
|
||||
@@ -478,18 +484,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
client_hash VARCHAR(64),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
form_type INTEGER NOT NULL,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
company VARCHAR(250),
|
||||
phone VARCHAR(50),
|
||||
message TEXT,
|
||||
source_url VARCHAR(1000),
|
||||
utm_source VARCHAR(250),
|
||||
utm_medium VARCHAR(250),
|
||||
utm_campaign VARCHAR(250),
|
||||
utm_term VARCHAR(250),
|
||||
utm_content VARCHAR(250),
|
||||
utm_source VARCHAR(500),
|
||||
utm_medium VARCHAR(500),
|
||||
utm_campaign VARCHAR(500),
|
||||
utm_term VARCHAR(500),
|
||||
utm_content VARCHAR(500),
|
||||
referrer VARCHAR(1000),
|
||||
contact_id INTEGER,
|
||||
conversion_status INTEGER DEFAULT 1 NOT NULL,
|
||||
@@ -674,3 +680,17 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
|
||||
--changeset qbit-crm:crm_job_state
|
||||
CREATE TABLE IF NOT EXISTS crm_job_state (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL UNIQUE,
|
||||
last_success TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_full_pass TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
--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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -124,4 +124,37 @@ public class BaseTest
|
||||
CrmQBitProducer.GROUP_ID + ":" + CrmQBitProducer.ARTIFACT_ID);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing operation regressions run helpers directly. CrmActionsTest covers ETL.
|
||||
*******************************************************************************/
|
||||
protected com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput runBusinessStep(
|
||||
com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput input) throws QException
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.actions.processes.BackendStep step;
|
||||
step = switch(input.getProcessName())
|
||||
{
|
||||
case "crm_auto_assign" -> new com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess();
|
||||
case "crm_enroll_in_sequence" -> new com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess();
|
||||
case "crm_unenroll_from_sequence" -> new com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess();
|
||||
case "crm_clone_deal" -> new com.kingsrook.qbits.crm.deals.processes.CloneDealProcess();
|
||||
case "crm_convert_lead" -> new com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess();
|
||||
case "crm_manage_contact_on_deal" -> new com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess();
|
||||
case "crm_move_deal_stage" -> new com.kingsrook.qbits.crm.deals.processes.MoveDealStageProcess();
|
||||
case "crm_reopen_deal" -> new com.kingsrook.qbits.crm.deals.processes.ReopenDealProcess();
|
||||
case "crm_transfer_ownership" -> new com.kingsrook.qbits.crm.deals.processes.TransferOwnershipProcess();
|
||||
case "crm_convert_form_to_contact" -> new com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess();
|
||||
case "crm_detect_duplicates" -> new com.kingsrook.qbits.crm.core.processes.DetectDuplicatesProcess();
|
||||
case "crm_gdpr_anonymize_contact" -> new com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess();
|
||||
case "crm_merge_companies" -> new com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess();
|
||||
case "crm_merge_contacts" -> new com.kingsrook.qbits.crm.core.processes.MergeContactsProcess();
|
||||
case "crm_subscribe_to_list" -> new com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess();
|
||||
case "crm_unsubscribe_from_list" -> new com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess();
|
||||
case "crm_log_activity" -> new com.kingsrook.qbits.crm.activities.processes.LogActivityProcess();
|
||||
default -> throw (new IllegalArgumentException(input.getProcessName()));
|
||||
};
|
||||
com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
|
||||
step.run(input, output);
|
||||
return (output);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -48,7 +48,6 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
@@ -103,7 +102,7 @@ class ContingentPossibleValuesTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional company links and backend-only process inputs stay unrestricted.
|
||||
** Optional activity links stay unrestricted; logging has standard ETL screens.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSkippedFields()
|
||||
@@ -112,8 +111,8 @@ class ContingentPossibleValuesTest extends BaseTest
|
||||
assertThat(activity.getField("companyId").getIsRequired()).isFalse();
|
||||
assertThat(activity.getField("contactId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(activity.getField("dealId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getStepList())
|
||||
.allMatch(step -> step instanceof QBackendStepMetaData);
|
||||
assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getFrontendStep("details")).isNotNull();
|
||||
assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getFrontendStep("review")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -70,7 +69,6 @@ class CrmAppMetaDataTest extends BaseTest
|
||||
assertThat(app.getWidgets()).containsExactlyElementsOf(CrmHomeWidgets.HOME);
|
||||
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("crm");
|
||||
assertThat(app.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
assertThat(((SourceQBitAware) app).getSourceQBitName()).isEqualTo("com.kingsrook.qbits:crm");
|
||||
assertThat(CrmQBitProducer.produceAppSection().getTables()).containsExactlyElementsOf(CrmAppMetaData.MAIN_TABLES);
|
||||
assertThat(QContext.getQInstance().getTable("crm_form_submission").getLabel()).isEqualTo("Web Leads");
|
||||
assertThat(QContext.getQInstance().getTable("crm_email_list").getLabel()).isEqualTo("Lists");
|
||||
@@ -90,7 +88,7 @@ class CrmAppMetaDataTest extends BaseTest
|
||||
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME).withAppEnabled(enabled))
|
||||
.produce(instance).addSelfToInstance(instance);
|
||||
assertThat(instance.getApps()).hasSize(Boolean.FALSE.equals(enabled) ? 0 : 1);
|
||||
assertThat(instance.getTables()).hasSize(33);
|
||||
assertThat(instance.getTables()).hasSize(34);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,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()
|
||||
@@ -149,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,163 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.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.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PII inventory and real tenant isolation through core QueryAction.
|
||||
*******************************************************************************/
|
||||
class CrmDataPolicyTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Complete pii inventory.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void completePiiInventory()
|
||||
{
|
||||
Map<String, List<String>> expected = Map.of(
|
||||
"crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1", "addressLine2",
|
||||
"city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"),
|
||||
"crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"),
|
||||
"crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"),
|
||||
"crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"),
|
||||
"crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"),
|
||||
"crm_consent_record", List.of("consentText"), "crm_activity", List.of("description"));
|
||||
TreeSet<String> actual = new TreeSet<>();
|
||||
QContext.getQInstance().getTables().values().forEach(table -> table.getFields().values().forEach(field ->
|
||||
{
|
||||
if(AiFieldMetaData.of(field) != null && AiFieldMetaData.of(field).getClassification() == DataClassification.PII)
|
||||
{
|
||||
actual.add(table.getName() + "." + field.getName());
|
||||
}
|
||||
}));
|
||||
TreeSet<String> inventory = new TreeSet<>();
|
||||
expected.forEach((table, fields) -> fields.forEach(field -> inventory.add(table + "." + field)));
|
||||
assertThat(actual).containsExactlyElementsOf(inventory);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Personal field names cannot lose classification.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void personalFieldNamesCannotLoseClassification()
|
||||
{
|
||||
Pattern pattern = Pattern.compile("email|phone|firstName|lastName|address|postal|linkedin", Pattern.CASE_INSENSITIVE);
|
||||
QContext.getQInstance().getTables().values().forEach(table -> table.getFields().values().forEach(field ->
|
||||
{
|
||||
// Foreign-key ids, preference booleans and transport message ids are not personal text.
|
||||
if(field.getType() == QFieldType.STRING && pattern.matcher(field.getName()).find() && !field.getName().equals("emailMessageId"))
|
||||
{
|
||||
assertThat(AiFieldMetaData.of(field)).as(table.getName() + "." + field.getName()).isNotNull();
|
||||
assertThat(AiFieldMetaData.of(field).getClassification()).isEqualTo(DataClassification.PII);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tenancy off has no fields or locks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tenancyOffHasNoFieldsOrLocks()
|
||||
{
|
||||
assertThat(new CrmQBitConfig().getTenantSecurityKeyType()).isNull();
|
||||
QContext.getQInstance().getTables().values().forEach(table ->
|
||||
{
|
||||
assertThat(table.getFields().get("tenantKey")).isNull();
|
||||
assertThat(table.getRecordSecurityLocks()).isNullOrEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tenant asees only its contacts deals and joined products.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tenantASeesOnlyItsContactsDealsAndJoinedProducts() throws Exception
|
||||
{
|
||||
QInstance instance = P4TestSupport.host();
|
||||
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
|
||||
.withTenantSecurityKeyType("tenant").withTenantAllAccessKeyName("allTenants")).produce(instance).addSelfToInstance(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
QContext.init(instance, new QSession().withPermissions(CrmPermissions.ALL).withSecurityKeyValue("tenant", "A"));
|
||||
for(String table : List.of("crm_contact", "crm_deal"))
|
||||
{
|
||||
P4TestSupport.seed(table, new QRecord().withValue("id", 1).withValue("tenantKey", "A"),
|
||||
new QRecord().withValue("id", 2).withValue("tenantKey", "B"), new QRecord().withValue("id", 3));
|
||||
}
|
||||
P4TestSupport.seed("crm_deal_product", new QRecord().withValue("id", 11).withValue("dealId", 1),
|
||||
new QRecord().withValue("id", 22).withValue("dealId", 2));
|
||||
assertThat(ids("crm_contact")).containsExactly(1);
|
||||
assertThat(ids("crm_deal")).containsExactly(1);
|
||||
assertThat(ids("crm_deal_product")).containsExactly(11);
|
||||
QContext.getQSession().withSecurityKeyValue("allTenants", true);
|
||||
assertThat(ids("crm_contact")).contains(1, 2);
|
||||
assertThat(ids("crm_deal")).contains(1, 2);
|
||||
assertThat(ids("crm_deal_product")).containsExactlyInAnyOrder(11, 22);
|
||||
instance.getTables().values().forEach(table -> assertThat(table.getRecordSecurityLocks()).as(table.getName()).isNotEmpty());
|
||||
assertThat(instance.getTable("crm_deal_product").getFields().get("tenantKey")).isNull();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserves host key type.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void preservesHostKeyType() throws Exception
|
||||
{
|
||||
QInstance instance = P4TestSupport.host();
|
||||
QSecurityKeyType key = new QSecurityKeyType().withName("tenant").withAllAccessKeyName("hostAll");
|
||||
instance.addSecurityKeyType(key);
|
||||
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
|
||||
.withTenantSecurityKeyType("tenant").withTenantAllAccessKeyName("ignored")).produce(instance).addSelfToInstance(instance);
|
||||
assertThat(instance.getSecurityKeyType("tenant")).isSameAs(key);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read through real record locks.
|
||||
*******************************************************************************/
|
||||
private List<Integer> ids(String table) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table)).getRecords().stream().map(row -> row.getValueInteger("id")).toList());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.bulkload.CrmBulkLoadProfiles;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
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.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.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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maintenance health thresholds and host-owned bulk-load profile seeding.
|
||||
*******************************************************************************/
|
||||
class CrmHealthBulkLoadTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No transport or score run needs attention.
|
||||
*******************************************************************************/
|
||||
@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 ->
|
||||
{
|
||||
try
|
||||
{
|
||||
assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Healthy then late jobs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void healthyThenLateJobs() throws Exception
|
||||
{
|
||||
CrmEmailTransport transport = new CrmEmailTransport(new CrmQBitConfig())
|
||||
{
|
||||
@Override
|
||||
public CrmReadiness readiness()
|
||||
{
|
||||
return (CrmReadiness.CORE_MESSAGING);
|
||||
}
|
||||
};
|
||||
CrmHealthChecks checks = new CrmHealthChecks(transport);
|
||||
P4TestSupport.seed("crm_job_state", new QRecord().withValue("id", 1).withValue("name", CalculateLeadScoresProcess.NAME)
|
||||
.withValue("lastSuccess", Instant.now().minus(2, ChronoUnit.HOURS)));
|
||||
P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("status", CrmEnrollmentStatus.ACTIVE.getId())
|
||||
.withValue("nextStepDate", Instant.now().minus(29, ChronoUnit.MINUTES)),
|
||||
new QRecord().withValue("id", 2).withValue("status", CrmEnrollmentStatus.UNENROLLED.getId())
|
||||
.withValue("nextStepDate", Instant.now().minus(2, ChronoUnit.DAYS)));
|
||||
assertThat(checks.run().values()).allMatch(result -> result.getStatus() == HealthStatus.UP);
|
||||
com.kingsrook.qbits.crm.core.CrmRecords.update("crm_job_state", new QRecord().withValue("id", 1)
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Health failure reports unknown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void healthFailureReportsUnknown() throws Exception
|
||||
{
|
||||
CrmHealthChecks checks = new CrmHealthChecks(new CrmEmailTransport(new CrmQBitConfig())
|
||||
{
|
||||
@Override
|
||||
public CrmReadiness readiness()
|
||||
{
|
||||
throw (new IllegalStateException("unavailable"));
|
||||
}
|
||||
});
|
||||
assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Profiles are optional idempotent and valid core mappings.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void profilesAreOptionalIdempotentAndValidCoreMappings() throws Exception
|
||||
{
|
||||
CrmBulkLoadProfiles.ensure();
|
||||
assertThat(QContext.getQInstance().getTable("savedBulkLoadProfile")).isNull();
|
||||
var table = new SavedBulkLoadProfileMetaDataProvider().defineSavedBulkLoadProfileTable(BACKEND_NAME, ignored ->
|
||||
{
|
||||
// Host has no additional backend details.
|
||||
});
|
||||
QContext.getQInstance().addTable(table);
|
||||
CrmBulkLoadProfiles.ensure();
|
||||
CrmBulkLoadProfiles.ensure();
|
||||
var rows = new QueryAction().execute(new QueryInput("savedBulkLoadProfile")).getRecords();
|
||||
assertThat(rows).hasSize(3);
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
BulkLoadProfile profile = new ObjectMapper().readValue(row.getValueString("mappingJson"), BulkLoadProfile.class);
|
||||
assertThat(profile.getHasHeaderRow()).isTrue();
|
||||
assertThat(profile.getLayout()).isEqualTo("FLAT");
|
||||
assertThat(profile.getFieldList()).isNotEmpty();
|
||||
profile.getFieldList().forEach(field -> assertThat(QContext.getQInstance().getTable(row.getValueString("tableName")).getField(field.getFieldName())).isNotNull());
|
||||
if(row.getValueString("tableName").equals("crm_contact"))
|
||||
{
|
||||
assertThat(profile.getFieldList().stream().map(field -> field.getHeaderName())).containsExactlyElementsOf(
|
||||
List.of("First Name", "Last Name", "Email", "Phone", "Company", "Job Title"));
|
||||
}
|
||||
}
|
||||
QRecord first = rows.getFirst();
|
||||
com.kingsrook.qbits.crm.core.CrmRecords.update("savedBulkLoadProfile", new QRecord().withValue("id", first.getValue("id"))
|
||||
.withValue("mappingJson", "{\"hostEdited\":true}"));
|
||||
CrmBulkLoadProfiles.ensure();
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.tables.GetAction.execute("savedBulkLoadProfile", first.getValue("id"))
|
||||
.getValueString("mappingJson")).contains("hostEdited");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Health middleware invokes checks in worker threads without a caller QContext.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1);
|
||||
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
var result = indicator.check(instance);
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
assertThat(QContext.getQSession()).isNull();
|
||||
return (result.getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
}).get();
|
||||
assertThat(status).isEqualTo(HealthStatus.UP);
|
||||
assertThat(QContext.getQInstance()).isSameAs(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Regression: indicators registered with no caller session (at host startup)
|
||||
** must still run on a host whose authentication rejects tokenless sessions
|
||||
** (OAuth2, Auth0). They used a plain new QSession(), so every check failed
|
||||
** session validation and reported UNKNOWN.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("token-required")
|
||||
.withType(QAuthenticationType.MOCK)
|
||||
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
|
||||
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1);
|
||||
return (indicator.check(instance).getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
}).get();
|
||||
assertThat(status).isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
|
||||
import com.kingsrook.qbits.crm.privacy.CrmPersonalData;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed all CRM copies and audit copies; verify no original personal strings survive.
|
||||
*******************************************************************************/
|
||||
class CrmPersonalDataTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Erasure covers all copies including audit values and messages.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void erasureCoversAllCopiesIncludingAuditValuesAndMessages() throws Exception
|
||||
{
|
||||
new AuditsMetaDataProvider().defineStandardAuditTables(QContext.getQInstance(), BACKEND_NAME, ignored ->
|
||||
{
|
||||
// Host has no additional backend details.
|
||||
});
|
||||
List<String> originals = new ArrayList<>();
|
||||
QRecord contact = personal(new QRecord().withValue("id", 1), originals, "firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone",
|
||||
"addressLine1", "addressLine2", "city", "state", "postalCode", "country", "timezone", "jobTitle", "department", "website", "linkedinUrl", "description");
|
||||
P4TestSupport.seed("crm_contact", contact);
|
||||
P4TestSupport.seed("crm_form_submission", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals,
|
||||
"firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText"),
|
||||
personal(new QRecord().withValue("id", 2), originals, "firstName", "lastName", "phone", "message", "clientHash", "visitorKey")
|
||||
.withValue("email", contact.getValue("email")));
|
||||
P4TestSupport.seed("crm_email_list_member", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals,
|
||||
"email", "firstName", "confirmationTokenHash", "consentText"));
|
||||
P4TestSupport.seed("crm_activity", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals, "description", "subject"));
|
||||
P4TestSupport.seed("crm_email_message", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals,
|
||||
"fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText"),
|
||||
personal(new QRecord().withValue("id", 2).withValue("activityId", 1), originals, "fromAddress", "toAddresses", "subject"));
|
||||
P4TestSupport.seed("crm_consent_record", personal(new QRecord().withValue("id", 1).withValue("contactId", 1)
|
||||
.withValue("consentType", CrmConsentType.EMAIL.getId()).withValue("status", CrmConsentStatus.GRANTED.getId()), originals, "consentText"));
|
||||
P4TestSupport.seed("crm_external_mapping", personal(new QRecord().withValue("id", 1).withValue("entityId", 1)
|
||||
.withValue("entityType", CrmEntityType.CONTACT.getId()), originals, "externalId"));
|
||||
P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("contactId", 1)
|
||||
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("nextStepDate", Instant.now()));
|
||||
int id = 1;
|
||||
for(String table : List.of("crm_contact", "crm_form_submission", "crm_email_list_member", "crm_activity", "crm_email_message",
|
||||
"crm_consent_record", "crm_external_mapping", "crm_sequence_enrollment"))
|
||||
{
|
||||
P4TestSupport.seed("auditTable", new QRecord().withValue("id", id).withValue("name", table));
|
||||
P4TestSupport.seed("audit", personal(new QRecord().withValue("id", (long) id).withValue("auditTableId", id).withValue("recordId", 1), originals, "message"));
|
||||
P4TestSupport.seed("auditDetail", personal(new QRecord().withValue("id", (long) id).withValue("auditId", (long) id), originals,
|
||||
"oldValue", "newValue", "message"));
|
||||
id++;
|
||||
}
|
||||
CrmPersonalData.erase(1);
|
||||
for(var table : QContext.getQInstance().getTables().values())
|
||||
{
|
||||
for(QRecord row : new QueryAction().execute(new QueryInput(table.getName())).getRecords())
|
||||
{
|
||||
for(Object value : row.getValues().values())
|
||||
{
|
||||
if(value instanceof String text)
|
||||
{
|
||||
originals.forEach(original -> assertThat(text).as(table.getName() + ":" + row.getValue("id")).doesNotContain(original));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(new QueryAction().execute(new QueryInput("crm_external_mapping")).getRecords()).isEmpty();
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.UNENROLLED.getId());
|
||||
var consents = new QueryAction().execute(new QueryInput("crm_consent_record")).getRecords();
|
||||
assertThat(consents).hasSize(2);
|
||||
assertThat(consents).anySatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueInteger("status")).isEqualTo(CrmConsentStatus.WITHDRAWN.getId());
|
||||
assertThat(row.getValueInteger("consentType")).isEqualTo(CrmConsentType.DATA_PROCESSING.getId());
|
||||
assertThat(row.getValueString("consentText")).startsWith("Erased on ");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing contact fails and ordinary consent editing stays forbidden.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingContactFailsAndOrdinaryConsentEditingStaysForbidden() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> CrmPersonalData.erase(999)).hasMessageContaining("not found");
|
||||
P4TestSupport.seed("crm_consent_record", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("consentText", "original"));
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput("crm_consent_record")
|
||||
.withRecord(new QRecord().withValue("id", 1).withValue("consentText", "erased")))).hasMessageContaining("cannot be modified");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Distinct sentinels make missing field coverage visible without false matches.
|
||||
*******************************************************************************/
|
||||
private QRecord personal(QRecord record, List<String> originals, String... fields)
|
||||
{
|
||||
for(String field : fields)
|
||||
{
|
||||
String value = "pii-sentinel-" + originals.size() + "-" + field;
|
||||
originals.add(value);
|
||||
record.withValue(field, value);
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction;
|
||||
import com.kingsrook.qqq.api.model.APIVersion;
|
||||
import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput;
|
||||
import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData;
|
||||
import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer;
|
||||
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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Published fields, search declaration and shipped help coverage.
|
||||
*******************************************************************************/
|
||||
class CrmPublicationHelpTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Api matches every published table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void apiMatchesEveryPublishedTable() throws Exception
|
||||
{
|
||||
QInstance instance = P4TestSupport.host();
|
||||
instance.withSupplementalMetaData(new ApiInstanceMetaDataContainer().withApiInstanceMetaData(new ApiInstanceMetaData()
|
||||
.withName("host").withPath("/api/").withLabel("Host API").withDescription("CRM test API").withContactEmail("test@example.invalid").withCurrentVersion(new APIVersion("v1")).withSupportedVersions(List.of(new APIVersion("v1")))));
|
||||
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
|
||||
.withApiName("host").withApiVersion("v1")).produce(instance).addSelfToInstance(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
QContext.setQInstance(instance);
|
||||
GetTableApiFieldsAction.clearCaches();
|
||||
List<String> published = new ArrayList<>(CrmAppMetaData.MAIN_TABLES);
|
||||
published.addAll(CrmAppMetaData.SETTINGS_TABLES);
|
||||
for(String name : published)
|
||||
{
|
||||
var table = instance.getTable(name);
|
||||
assertThat(ApiTableMetaDataContainer.of(table).getApiTableMetaData("host")).isNotNull();
|
||||
var fields = GetTableApiFieldsAction.getTableApiFieldMap(new GetTableApiFieldsInput().withApiName("host").withVersion("v1").withTableName(name));
|
||||
assertThat(fields.keySet()).as(name).containsExactlyInAnyOrderElementsOf(table.getFields().keySet());
|
||||
}
|
||||
assertThat(ApiTableMetaDataContainer.of(instance.getTable("crm_deal_product"))).isNull();
|
||||
GetTableApiFieldsAction.clearCaches();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Esb fails closed until core excludes classified values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void esbFailsClosedUntilCoreExcludesClassifiedValues()
|
||||
{
|
||||
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig()
|
||||
.withDefaultBackendNameForTables(BACKEND_NAME).withEsbDestinationName("crmEvents")).produce(P4TestSupport.host()))
|
||||
.hasMessageContaining("classified-value exclusion");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Main tables declare search fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mainTablesDeclareSearchFields()
|
||||
{
|
||||
for(String table : CrmAppMetaData.MAIN_TABLES)
|
||||
{
|
||||
assertThat(QContext.getQInstance().getTable(table).getSearchFields()).isNotEmpty();
|
||||
}
|
||||
assertThat(QContext.getQInstance().getTable("crm_contact").getSearchFields()).containsExactly("firstName", "lastName", "email");
|
||||
assertThat(QContext.getQInstance().getTable("crm_company").getSearchFields()).containsExactly("name", "domain");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native registration is safe without help tables and covers all CRM metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpCoversOwnedMetadataWithoutHelpTable()
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,7 @@ package com.kingsrook.qbits.crm;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -50,26 +47,20 @@ class CrmQBitConfigTest
|
||||
@Test
|
||||
void testFluentSettersAndGetters()
|
||||
{
|
||||
List<QFieldMetaData> securityFields = List.of(
|
||||
new QFieldMetaData("tenantId", QFieldType.INTEGER));
|
||||
|
||||
List<RecordSecurityLock> locks = List.of(
|
||||
new RecordSecurityLock().withFieldName("tenantId"));
|
||||
|
||||
MetaDataCustomizerInterface<QTableMetaData> customizer = (qInstance, table) -> table;
|
||||
|
||||
CrmQBitConfig config = new CrmQBitConfig()
|
||||
.withDefaultBackendNameForTables("myBackend")
|
||||
.withInferBackendNames(false)
|
||||
.withTableMetaDataCustomizer(customizer)
|
||||
.withSecurityFields(securityFields)
|
||||
.withRecordSecurityLocks(locks);
|
||||
.withTenantSecurityKeyType("tenant")
|
||||
.withTenantAllAccessKeyName("allTenants");
|
||||
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("myBackend");
|
||||
assertThat(config.getInferBackendNames()).isFalse();
|
||||
assertThat(config.getTableMetaDataCustomizer()).isSameAs(customizer);
|
||||
assertThat(config.getSecurityFields()).isSameAs(securityFields);
|
||||
assertThat(config.getRecordSecurityLocks()).isSameAs(locks);
|
||||
assertThat(config.getTenantSecurityKeyType()).isEqualTo("tenant");
|
||||
assertThat(config.getTenantAllAccessKeyName()).isEqualTo("allTenants");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -142,7 +142,7 @@ class CrmQBitProducerTest extends BaseTest
|
||||
{
|
||||
assertThat(QContext.getQInstance().getTable(tableName).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
}
|
||||
assertThat(QContext.getQInstance().getTables()).hasSize(33)
|
||||
assertThat(QContext.getQInstance().getTables()).hasSize(34)
|
||||
.doesNotContainKeys("crm_tag", "crm_contact_tag", "crm_company_tag", "crm_deal_tag", "crm_attachment", "crm_audit_log");
|
||||
assertThat(QContext.getQInstance().getProcesses()).doesNotContainKeys("crm_bulk_add_tag", "crm_bulk_remove_tag");
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ class CrmRdbmsTest
|
||||
@Test
|
||||
void testSchemaMatchesMetadata() throws Exception
|
||||
{
|
||||
assertThat(qInstance.getTables()).hasSize(33);
|
||||
assertThat(qInstance.getTables()).hasSize(34);
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
Set<String> columns = new HashSet<>();
|
||||
@@ -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(33);
|
||||
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);
|
||||
@@ -370,4 +391,31 @@ class CrmRdbmsTest
|
||||
assertThat(leaders.getRows().getFirst().get("first").toString()).endsWith(">Ada</a>");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The base schema stays unchanged; only the explicit tenant migration adds keys.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOptionalTenantSchemaMatchesMetadata() throws Exception
|
||||
{
|
||||
try(var columns = connection.getMetaData().getColumns(null, null, "crm_contact", "tenant_key"))
|
||||
{
|
||||
assertThat(columns.next()).isFalse();
|
||||
}
|
||||
executeSql(Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql")));
|
||||
QInstance tenantInstance = new QInstance();
|
||||
tenantInstance.addBackend(qInstance.getBackend("rdbms"));
|
||||
tenantInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables("rdbms")
|
||||
.withTenantSecurityKeyType("tenant")).produce(tenantInstance).addSelfToInstance(tenantInstance);
|
||||
new QInstanceValidator().validate(tenantInstance);
|
||||
for(var table : tenantInstance.getTables().values())
|
||||
{
|
||||
try(var columns = connection.getMetaData().getColumns(null, null, table.getName(), "tenant_key"))
|
||||
{
|
||||
assertThat(columns.next()).as(table.getName()).isEqualTo(table.getFields().containsKey("tenantKey"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Relationship validation must cover insert and either side of partial updates.
|
||||
*******************************************************************************/
|
||||
class CrmRelationshipValidationTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Rejects wrong pipeline on insert and partial updates.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsWrongPipelineOnInsertAndPartialUpdates() throws Exception
|
||||
{
|
||||
P4TestSupport.seed("crm_pipeline_stage", new QRecord().withValue("id", 10).withValue("pipelineId", 1),
|
||||
new QRecord().withValue("id", 20).withValue("pipelineId", 2));
|
||||
var inserted = new InsertAction().execute(new InsertInput("crm_deal").withRecord(new QRecord().withValue("name", "Wrong stage")
|
||||
.withValue("pipelineId", 1).withValue("pipelineStageId", 20)));
|
||||
assertThat(inserted.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
P4TestSupport.seed("crm_deal", new QRecord().withValue("id", 1).withValue("name", "Existing").withValue("pipelineId", 1).withValue("pipelineStageId", 10));
|
||||
for(QRecord change : List.of(new QRecord().withValue("id", 1).withValue("pipelineId", 2),
|
||||
new QRecord().withValue("id", 1).withValue("pipelineStageId", 20)))
|
||||
{
|
||||
assertThat(new UpdateAction().execute(new UpdateInput("crm_deal").withRecord(change)).getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validates activity outcome and allows null outcome.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validatesActivityOutcomeAndAllowsNullOutcome() throws Exception
|
||||
{
|
||||
P4TestSupport.seed("crm_activity_outcome", new QRecord().withValue("id", 10).withValue("activityTypeId", 1));
|
||||
var bad = new InsertAction().execute(new InsertInput("crm_activity").withRecord(new QRecord().withValue("subject", "Wrong outcome")
|
||||
.withValue("activityTypeId", 2).withValue("outcomeId", 10)));
|
||||
assertThat(bad.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
var good = new InsertAction().execute(new InsertInput("crm_activity").withRecord(new QRecord().withValue("subject", "Call").withValue("ownerUserId", TEST_USER_ID)
|
||||
.withValue("activityTypeId", 1).withValue("outcomeId", 10)));
|
||||
assertThat(good.getRecords().getFirst().getErrors()).isNullOrEmpty();
|
||||
var cleared = new UpdateAction().execute(new UpdateInput("crm_activity").withRecord(new QRecord()
|
||||
.withValue("id", good.getRecords().getFirst().getValue("id")).withValue("outcomeId", null)));
|
||||
assertThat(cleared.getRecords().getFirst().getErrors()).isNullOrEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Isolated host instances and fixture inserts, without bypassing actions under test.
|
||||
*******************************************************************************/
|
||||
final class P4TestSupport
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private P4TestSupport()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a host without CRM, ready for configuration-specific production.
|
||||
*******************************************************************************/
|
||||
static QInstance host()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture storage intentionally avoids business side effects.
|
||||
*******************************************************************************/
|
||||
static void seed(String table, QRecord... rows) throws QException
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecords(java.util.List.of(rows)), false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.crm.actions.CrmAction;
|
||||
import com.kingsrook.qbits.crm.actions.CrmActions;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
|
||||
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;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
|
||||
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmails;
|
||||
import com.kingsrook.qbits.crm.email.processes.ExpireConsentsProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.ProcessSequenceStepsProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.SyncEmailDeliveryProcess;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
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.code.QCodeReference;
|
||||
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.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** P4 contracts exercised through memory DML, fake transport and real QAlerts dispatch.
|
||||
*******************************************************************************/
|
||||
class PlatformReuseTest extends BaseTest
|
||||
{
|
||||
private FakeTransport transport;
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep effects local to this test.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void configure() throws Exception
|
||||
{
|
||||
transport = new FakeTransport();
|
||||
CrmApi.config().withPublicBaseUrl("https://crm.example.test").withTokenSecret("test-only-signing-key");
|
||||
Sink.notifications.clear();
|
||||
Sink.alerts.clear();
|
||||
Sink.resolutions.clear();
|
||||
AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance()).withSink(new QCodeReference(Sink.class));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send action renders and records transactional delivery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendActionRendersAndRecordsTransactionalDelivery() throws Exception
|
||||
{
|
||||
QRecord contact = contact(1).withValue("companyId", 1);
|
||||
put("crm_company", new QRecord().withValue("id", 1).withValue("name", "Example"));
|
||||
put("crm_contact", contact);
|
||||
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "Hello {{contact.firstName}}")
|
||||
.withValue("bodyText", "{{contact.lastName}} at {{company.name}} / {{owner.name}} / {{unsubscribeUrl}}"));
|
||||
CrmAction.Load load = load();
|
||||
RunBackendStepInput input = new RunBackendStepInput().withProcessName("crm_send_email").withRecords(List.of(contact))
|
||||
.addValue("emailTemplateId", 1).addValue("crmEmailProcessUUID", "run-123");
|
||||
load.runOnePage(input, new RunBackendStepOutput());
|
||||
assertThat(transport.key).isEqualTo("crm:email:1:run-123");
|
||||
assertThat(transport.marketing).isFalse();
|
||||
assertThat(transport.subject).isEqualTo("Hello Alice");
|
||||
assertThat(transport.body).startsWith("Smith at Example / owner / https://crm.example.test/qqq/hooks/crm/unsubscribe/");
|
||||
assertThat(rows("crm_email_message")).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueInteger("status")).isEqualTo(CrmEmailStatus.QUEUED.getId());
|
||||
assertThat(row.getValueString("emailMessageId")).isEqualTo("provider-1");
|
||||
assertThat(row.getValueString("idempotencyKey")).isEqualTo(transport.key);
|
||||
});
|
||||
assertActivity("EMAIL");
|
||||
load.runOnePage(input, new RunBackendStepOutput());
|
||||
assertThat(transport.sends).isEqualTo(1);
|
||||
assertThat(rows("crm_activity")).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Overrides and missing templates and literal merge values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void overridesAndMissingTemplatesAndLiteralMergeValues() throws Exception
|
||||
{
|
||||
QRecord contact = contact(1).withValue("firstName", "{{owner.name}}");
|
||||
assertThat(CrmEmails.render("{{contact.firstName}} {{contact.lastName}}", contact)).isEqualTo("{{owner.name}} Smith");
|
||||
assertThrows(QException.class, () -> CrmEmails.send(transport, contact, 999, null, null, "email", "missing", null));
|
||||
assertThrows(QException.class, () -> CrmEmails.send(transport, contact, null, "subject", "", "email", "missing", null));
|
||||
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "template").withValue("bodyText", "template"));
|
||||
CrmEmails.send(transport, contact, 1, "override", "body", "email", "1:run", null);
|
||||
assertThat(transport.subject).isEqualTo("override");
|
||||
assertThat(transport.body).isEqualTo("body");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Skips opt out missing address and unavailable transport.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void skipsOptOutMissingAddressAndUnavailableTransport() throws Exception
|
||||
{
|
||||
List<QRecord> contacts = List.of(contact(1).withValue("doNotEmail", true), contact(2).withValue("email", null), contact(3));
|
||||
for(QRecord contact : contacts)
|
||||
{
|
||||
put("crm_contact", contact);
|
||||
}
|
||||
transport.ready = false;
|
||||
CrmAction.Load load = load();
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
load.runOnePage(new RunBackendStepInput().withProcessName("crm_send_email").withRecords(contacts), output);
|
||||
assertThat(load.getProcessSummary(output, true)).hasSize(3);
|
||||
assertThat(transport.sends).isZero();
|
||||
assertThat(rows("crm_email_message")).isEmpty();
|
||||
assertThat(rows("crm_activity")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Process identity uses core uuid.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void processIdentityUsesCoreUuid() throws Exception
|
||||
{
|
||||
var output = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_send_email"));
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getValues().get("crmEmailProcessUUID")).isEqualTo(output.getProcessUUID());
|
||||
var listener = new CrmEmailProcessIdentity();
|
||||
assertThat(listener.appliesTo("crm_send_email")).isTrue();
|
||||
assertThat(listener.appliesTo("crm_other")).isFalse();
|
||||
listener.onProcessCompleted(new RunProcessInput(), output);
|
||||
listener.onProcessFailed(new RunProcessInput(), new Exception("test"));
|
||||
assertThat(CrmActions.inputFields(CrmActions.action("crm_send_email"))).extracting(field -> field.getName())
|
||||
.containsExactly("emailTemplateId", "subject", "body");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active sequence sends marketing and advances once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void activeSequenceSendsMarketingAndAdvancesOnce() throws Exception
|
||||
{
|
||||
seedSequence(true, true);
|
||||
sequence();
|
||||
assertThat(transport.key).isEqualTo("crm:seq:1:1");
|
||||
assertThat(transport.marketing).isTrue();
|
||||
assertThat(rows("crm_email_message")).hasSize(1);
|
||||
assertActivity("SEQUENCE_STEP");
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.COMPLETED.getId());
|
||||
sequence();
|
||||
assertThat(transport.sends).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Paused sequence does not advance or send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pausedSequenceDoesNotAdvanceOrSend() throws Exception
|
||||
{
|
||||
seedSequence(false, true);
|
||||
sequence();
|
||||
assertThat(transport.sends).isZero();
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("currentStepNumber")).isZero();
|
||||
assertThat(Sink.alerts).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing consent pauses enrollment with reason.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingConsentPausesEnrollmentWithReason() throws Exception
|
||||
{
|
||||
seedSequence(true, false);
|
||||
sequence();
|
||||
QRecord enrollment = GetAction.execute("crm_sequence_enrollment", 1);
|
||||
assertThat(enrollment.getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.PAUSED.getId());
|
||||
assertThat(enrollment.getValueString("lastFailureMessage")).isEqualTo("no_email_consent");
|
||||
assertThat(enrollment.getValueInteger("failureCount")).isZero();
|
||||
assertThat(transport.sends).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport failures raise on third attempt.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void transportFailuresRaiseOnThirdAttempt() throws Exception
|
||||
{
|
||||
seedSequence(true, true);
|
||||
transport.fail = true;
|
||||
sequence();
|
||||
sequence();
|
||||
sequence();
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("failureCount")).isEqualTo(3);
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.FAILED.getId());
|
||||
assertThat(Sink.alerts).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAlertTypeName()).isEqualTo("crm.sequence.failing");
|
||||
assertThat(signal.getDedupKey()).isEqualTo("enrollment:1");
|
||||
});
|
||||
assertThat(rows("crm_email_message")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unavailable transport raises then resolves without failing enrollment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableTransportRaisesThenResolvesWithoutFailingEnrollment() throws Exception
|
||||
{
|
||||
seedSequence(true, true);
|
||||
transport.ready = false;
|
||||
sequence();
|
||||
assertThat(Sink.alerts).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAlertTypeName()).isEqualTo("crm.email.unavailable");
|
||||
assertThat(signal.getDedupKey()).isEqualTo("transport");
|
||||
});
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("failureCount")).isZero();
|
||||
transport.ready = true;
|
||||
sequence();
|
||||
assertThat(Sink.resolutions).hasSize(1);
|
||||
assertThat(transport.sends).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery sync uses recent nonfinal messages and preserves dates.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deliverySyncUsesRecentNonfinalMessagesAndPreservesDates() throws Exception
|
||||
{
|
||||
contact(1);
|
||||
message(1, CrmEmailStatus.SENT, Instant.now());
|
||||
message(2, CrmEmailStatus.SENT, Instant.now().minus(31, ChronoUnit.DAYS));
|
||||
message(3, CrmEmailStatus.FAILED, Instant.now());
|
||||
Instant now = Instant.now();
|
||||
transport.deliveries.put("key:1", new CrmEmailTransport.Delivery("provider", CrmEmailStatus.CLICKED, now, now, now, now, null));
|
||||
sync();
|
||||
assertThat(transport.lookups).containsExactly("key:1");
|
||||
QRecord message = GetAction.execute("crm_email_message", 1);
|
||||
assertThat(message.getValueInstant("deliveredDate")).isEqualTo(now);
|
||||
assertThat(message.getValueInstant("firstOpenedDate")).isEqualTo(now);
|
||||
assertThat(message.getValueInstant("firstClickedDate")).isEqualTo(now);
|
||||
transport.deliveries.put("key:1", new CrmEmailTransport.Delivery("provider", CrmEmailStatus.CLICKED, null, null, null, null, null));
|
||||
sync();
|
||||
assertThat(GetAction.execute("crm_email_message", 1).getValueInstant("firstOpenedDate")).isEqualTo(now);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounce and complaint withdraw once and suppress contact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bounceAndComplaintWithdrawOnceAndSuppressContact() throws Exception
|
||||
{
|
||||
for(int id = 1; id <= 2; id++)
|
||||
{
|
||||
contact(id);
|
||||
message(id, CrmEmailStatus.DELIVERED, Instant.now());
|
||||
CrmEmailStatus status = id == 1 ? CrmEmailStatus.BOUNCED : CrmEmailStatus.COMPLAINED;
|
||||
transport.deliveries.put("key:" + id, new CrmEmailTransport.Delivery("provider", status, null, null, null, null, Instant.now()));
|
||||
}
|
||||
sync();
|
||||
sync();
|
||||
assertThat(rows("crm_contact")).allSatisfy(row -> assertThat(row.getValueBoolean("doNotEmail")).isTrue());
|
||||
assertThat(rows("crm_consent_record")).hasSize(2).allSatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueInteger("consentType")).isEqualTo(CrmConsentType.EMAIL.getId());
|
||||
assertThat(row.getValueInteger("status")).isEqualTo(CrmConsentStatus.WITHDRAWN.getId());
|
||||
});
|
||||
assertThat(rows("crm_consent_record")).extracting(row -> row.getValueString("source")).containsExactlyInAnyOrder("Hard bounce", "Complaint");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Absent email qbit is no op.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void absentEmailQbitIsNoOp() throws Exception
|
||||
{
|
||||
message(1, CrmEmailStatus.SENT, Instant.now());
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
new SyncEmailDeliveryProcess().run(new RunBackendStepInput(), output);
|
||||
assertThat(output.getValueInteger("updatedCount")).isZero();
|
||||
assertThat(new CrmEmailTransport(CrmApi.config()).lookup("key:1")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stale pending members expire without new consent rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stalePendingMembersExpireWithoutNewConsentRows() throws Exception
|
||||
{
|
||||
for(int id = 1; id <= 3; id++)
|
||||
{
|
||||
put("crm_email_list_member", new QRecord().withValue("id", id).withValue("emailListId", 1).withValue("email", "a" + id + "@example.test")
|
||||
.withValue("status", id == 3 ? CrmListMemberStatus.SUBSCRIBED.getId() : CrmListMemberStatus.PENDING_CONFIRMATION.getId())
|
||||
.withValue("confirmationSentDate", Instant.now().minus(id == 2 ? 13 : 15, ChronoUnit.DAYS)).withValue("confirmationTokenHash", "hash"));
|
||||
}
|
||||
put("crm_consent_record", new QRecord().withValue("id", 1).withValue("expiryDate", Instant.now().minusSeconds(1)));
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
new ExpireConsentsProcess().run(new RunBackendStepInput(), output);
|
||||
assertThat(output.getValueInteger("expiredPendingCount")).isEqualTo(1);
|
||||
assertThat(output.getValueInteger("expiredConsentCount")).isEqualTo(1);
|
||||
assertThat(GetAction.execute("crm_email_list_member", 1).getValue("confirmationTokenHash")).isNull();
|
||||
assertThat(GetAction.execute("crm_email_list_member", 1).getValueInteger("status")).isEqualTo(CrmListMemberStatus.UNSUBSCRIBED.getId());
|
||||
assertThat(GetAction.execute("crm_email_list_member", 2).getValueString("confirmationTokenHash")).isEqualTo("hash");
|
||||
assertThat(rows("crm_consent_record")).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Schedules require scheduler and can be disabled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void schedulesRequireSchedulerAndCanBeDisabled() throws Exception
|
||||
{
|
||||
Map<String, Integer> intervals = Map.of("crm_process_sequence_steps", 300, "crm_calculate_lead_scores", 3600,
|
||||
"crm_sync_email_delivery", 900, "crm_expire_consents", 86400);
|
||||
for(String name : intervals.keySet())
|
||||
{
|
||||
assertThat(QContext.getQInstance().getProcess(name).getSchedule()).isNull();
|
||||
}
|
||||
CrmQBitConfig config = new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME).withSchedulerName("jobs");
|
||||
var output = new CrmQBitProducer().withQBitConfig(config).produce(new com.kingsrook.qqq.backend.core.model.metadata.QInstance());
|
||||
for(var process : output.getEach(com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData.class))
|
||||
{
|
||||
if(intervals.containsKey(process.getName()))
|
||||
{
|
||||
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(intervals.get(process.getName()));
|
||||
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("jobs");
|
||||
}
|
||||
}
|
||||
assertThat(config.getSchedulesEnabled()).isTrue();
|
||||
output = new CrmQBitProducer().withQBitConfig(config.withSchedulesEnabled(false)).produce(new com.kingsrook.qqq.backend.core.model.metadata.QInstance());
|
||||
assertThat(output.getEach(com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData.class))
|
||||
.allSatisfy(process -> assertThat(process.getSchedule()).isNull());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration and alerts use core metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void collaborationAndAlertsUseCoreMetadata() throws Exception
|
||||
{
|
||||
for(String table : List.of("crm_contact", "crm_company", "crm_deal", "crm_activity"))
|
||||
{
|
||||
var features = RecordFeaturesMetaData.of(QContext.getQInstance().getTable(table));
|
||||
for(RecordFeature feature : List.of(RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.ATTACHMENTS,
|
||||
RecordFeature.FOLLOW, RecordFeature.TIMELINE, RecordFeature.RECENTS, RecordFeature.FAVORITES))
|
||||
{
|
||||
assertThat(features.getEnabled().get(feature)).isTrue();
|
||||
}
|
||||
assertThat(features.getOwnership().getOwnerFieldName()).isEqualTo("ownerUserId");
|
||||
assertThat(features.getEnabled().get(RecordFeature.TASKS)).isEqualTo("crm_activity".equals(table) ? null : true);
|
||||
assertThat(features.getEnabled().get(RecordFeature.ASSIGNMENT)).isEqualTo("crm_activity".equals(table) ? null : true);
|
||||
}
|
||||
assertThat(AlertsInstanceMetaData.of(QContext.getQInstance()).getAlertTypes()).containsKeys("crm.sequence.failing", "crm.email.unavailable",
|
||||
"crm.lead.assigned", "crm.deal.won", "crm.deal.rotting", "crm.task.due");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scoring uses dates incremental changes and daily full pass.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringUsesDatesIncrementalChangesAndDailyFullPass() throws Exception
|
||||
{
|
||||
put("crm_contact", contact(1).withValue("lastActivityDate", Instant.now().minus(20, ChronoUnit.DAYS)).withValue("modifyDate", Instant.now().minusSeconds(300)));
|
||||
put("crm_contact", contact(2).withValue("lastActivityDate", Instant.now().minus(2, ChronoUnit.DAYS)).withValue("modifyDate", Instant.now().minusSeconds(300)));
|
||||
rule(1, "lastActivityDate", CrmScoreOperator.DAYS_AGO_GT, "10", -5);
|
||||
rule(2, "lastActivityDate", CrmScoreOperator.DAYS_AGO_LT, "10", 10);
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(2);
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueInteger("leadScore")).isEqualTo(-5);
|
||||
assertThat(GetAction.execute("crm_contact", 2).getValueInteger("leadScore")).isEqualTo(10);
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isZero();
|
||||
CrmRecords.update("crm_contact", new QRecord().withValue("id", 2).withValue("firstName", "Changed"));
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(1);
|
||||
assertThat(scores(true).getValueInteger("totalContacts")).isEqualTo(2);
|
||||
QRecord state = rows("crm_job_state").getFirst();
|
||||
CrmRecords.update("crm_job_state", state.withValue("lastFullPass", Instant.now().minus(25, ChronoUnit.HOURS)));
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scoring handles calendar dates and malformed rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringHandlesCalendarDatesAndMalformedRules() throws Exception
|
||||
{
|
||||
put("crm_contact", contact(1).withValue("lastName", LocalDate.now().minusDays(30).toString()));
|
||||
rule(1, "lastName", CrmScoreOperator.DAYS_AGO_GT, "10", 7);
|
||||
rule(2, "firstName", CrmScoreOperator.DAYS_AGO_LT, "bad", 100);
|
||||
rule(3, "email", CrmScoreOperator.DAYS_AGO_GT, "10", 100);
|
||||
scores(true);
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueInteger("leadScore")).isEqualTo(7);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert assignment preserves owners and notifies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void insertAssignmentPreservesOwnersAndNotifies() throws Exception
|
||||
{
|
||||
for(CrmAssignableEntity entity : CrmAssignableEntity.values())
|
||||
{
|
||||
put("crm_assignment_rule", new QRecord().withValue("id", entity.getId()).withValue("name", "Assign")
|
||||
.withValue("entityType", entity.getId()).withValue("assignStrategy", CrmAssignStrategy.SPECIFIC_USER.getId())
|
||||
.withValue("assignToUserId", "assigned").withValue("isActive", true).withValue("sortOrder", 1));
|
||||
}
|
||||
QRecord inserted = CrmRecords.insert("crm_contact", new QRecord().withValue("firstName", "New").withValue("lastName", "Contact"));
|
||||
assertThat(GetAction.execute("crm_contact", inserted.getValue("id")).getValueString("ownerUserId")).isEqualTo("assigned");
|
||||
CrmRecords.insert("crm_contact", new QRecord().withValue("firstName", "Already").withValue("lastName", "Owned").withValue("ownerUserId", "explicit"));
|
||||
put("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales"));
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("pipelineId", 1).withValue("name", "Open"));
|
||||
QRecord deal = CrmRecords.insert("crm_deal", new QRecord().withValue("name", "New deal").withValue("pipelineId", 1)
|
||||
.withValue("pipelineStageId", 1).withValue("ownerUserId", "system").withValue("currencyCode", "USD"));
|
||||
assertThat(GetAction.execute("crm_deal", deal.getValue("id")).getValueString("ownerUserId")).isEqualTo("assigned");
|
||||
assertThat(Sink.notifications).hasSize(2).allSatisfy(signal ->
|
||||
{
|
||||
assertThat(signal.getSourceName()).isEqualTo("crm.lead.assigned");
|
||||
assertThat(signal.getAudience()).isEqualTo(NotificationAudience.user("assigned"));
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Won and rotting notifications target owner and deduplicate stage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wonAndRottingNotificationsTargetOwnerAndDeduplicateStage() throws Exception
|
||||
{
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("name", "Open").withValue("pipelineId", 1).withValue("rotDays", 5));
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("name", "Won").withValue("pipelineId", 1).withValue("isClosedWon", true));
|
||||
put("crm_deal", new QRecord().withValue("id", 1).withValue("name", "Old deal").withValue("pipelineId", 1)
|
||||
.withValue("pipelineStageId", 1).withValue("ownerUserId", "owner").withValue("stageEnteredDate", Instant.now().minus(10, ChronoUnit.DAYS)));
|
||||
CrmDealNotifications.checkRotting();
|
||||
CrmDealNotifications.checkRotting();
|
||||
assertThat(Sink.notifications).singleElement().satisfies(signal -> assertThat(signal.getSourceKey()).isEqualTo("deal:1:stage:1"));
|
||||
CrmRecords.update("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 2));
|
||||
assertThat(Sink.notifications).hasSize(2);
|
||||
assertThat(Sink.notifications.getLast().getSourceName()).isEqualTo("crm.deal.won");
|
||||
assertThat(Sink.notifications.getLast().getAudience()).isEqualTo(NotificationAudience.user("owner"));
|
||||
CrmRecords.update("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 2));
|
||||
CrmDealNotifications.checkRotting();
|
||||
assertThat(Sink.notifications).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Company loading scales with pages, not contacts or rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringLoadsCompaniesOncePerPage() throws Exception
|
||||
{
|
||||
put("crm_company", new QRecord().withValue("id", 1).withValue("name", "Example"));
|
||||
for(int id = 1; id <= 201; id++)
|
||||
{
|
||||
put("crm_contact", contact(id).withValue("companyId", 1));
|
||||
}
|
||||
rule(1, "company.name", CrmScoreOperator.EQUALS, "Example", 3);
|
||||
rule(2, "company.name", CrmScoreOperator.IS_SET, null, 2);
|
||||
CompanyReads.count = 0;
|
||||
QContext.getQInstance().getTable("crm_company").withCustomizer(
|
||||
com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD, new QCodeReference(CompanyReads.class));
|
||||
assertThat(scores(true).getValueInteger("updatedCount")).isEqualTo(201);
|
||||
// Two explicit page loads, plus at most two core audit-label translations.
|
||||
assertThat(CompanyReads.count).isBetween(2, 4);
|
||||
assertThat(GetAction.execute("crm_contact", 201).getValueInteger("leadScore")).isEqualTo(5);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed scoring must leave the successful checkpoint unchanged.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringFailureDoesNotAdvanceCheckpoint() throws Exception
|
||||
{
|
||||
contact(1);
|
||||
scores(true);
|
||||
Instant checkpoint = rows("crm_job_state").getFirst().getValueInstant("lastSuccess");
|
||||
rule(1, "firstName", CrmScoreOperator.IS_SET, null, 1);
|
||||
QContext.getQInstance().getTable("crm_contact").withCustomizer(
|
||||
com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(RejectScore.class));
|
||||
assertThrows(QException.class, () -> scores(true));
|
||||
assertThat(rows("crm_job_state").getFirst().getValueInstant("lastSuccess")).isEqualTo(checkpoint);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry repairs a missing activity without sending the persisted message again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retryRepairsActivityAfterPartialRecording() throws Exception
|
||||
{
|
||||
QRecord contact = contact(1);
|
||||
put("crm_email_message", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("subject", "Already sent")
|
||||
.withValue("idempotencyKey", "crm:email:1:retry").withValue("status", CrmEmailStatus.SENT.getId()));
|
||||
CrmEmails.send(transport, contact, null, "Already sent", "body", "email", "1:retry", null);
|
||||
assertThat(transport.sends).isZero();
|
||||
assertActivity("EMAIL");
|
||||
assertThat(GetAction.execute("crm_email_message", 1).getValue("activityId")).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count actual backend company queries.
|
||||
*******************************************************************************/
|
||||
public static class CompanyReads extends com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer
|
||||
{
|
||||
private static int count;
|
||||
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
count++;
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return a record-level error to exercise checked background writes.
|
||||
*******************************************************************************/
|
||||
public static class RejectScore extends com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
records.forEach(row -> row.addError(new com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage("Rejected score")));
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send action with a local fake.
|
||||
*******************************************************************************/
|
||||
private CrmAction.Load load()
|
||||
{
|
||||
return (new CrmAction.Load()
|
||||
{
|
||||
@Override
|
||||
protected CrmEmailTransport emailTransport()
|
||||
{
|
||||
return (transport);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequence runner with a local fake.
|
||||
*******************************************************************************/
|
||||
private void sequence() throws QException
|
||||
{
|
||||
new ProcessSequenceStepsProcess()
|
||||
{
|
||||
@Override
|
||||
protected CrmEmailTransport emailTransport()
|
||||
{
|
||||
return (transport);
|
||||
}
|
||||
}.run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sync with a local fake.
|
||||
*******************************************************************************/
|
||||
private void sync() throws QException
|
||||
{
|
||||
new SyncEmailDeliveryProcess()
|
||||
{
|
||||
@Override
|
||||
protected CrmEmailTransport emailTransport()
|
||||
{
|
||||
return (transport);
|
||||
}
|
||||
}.run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run score work using normal QQQ actions.
|
||||
*******************************************************************************/
|
||||
private RunBackendStepOutput scores(boolean full) throws QException
|
||||
{
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
new CalculateLeadScoresProcess().run(new RunBackendStepInput().addValue("fullRecalc", full), output);
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Contact fixture.
|
||||
*******************************************************************************/
|
||||
private QRecord contact(int id) throws QException
|
||||
{
|
||||
QRecord row = new QRecord().withValue("id", id).withValue("firstName", "Alice").withValue("lastName", "Smith")
|
||||
.withValue("email", "alice" + id + "@example.test").withValue("ownerUserId", "owner");
|
||||
put("crm_contact", row);
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequence fixtures use enum identifiers.
|
||||
*******************************************************************************/
|
||||
private void seedSequence(boolean active, boolean eligible) throws QException
|
||||
{
|
||||
contact(1);
|
||||
put("crm_email_sequence", new QRecord().withValue("id", 1).withValue("name", "Sequence").withValue("isActive", active).withValue("totalSteps", 1));
|
||||
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "Hello {{contact.firstName}}").withValue("bodyText", "Welcome"));
|
||||
put("crm_sequence_step", new QRecord().withValue("id", 1).withValue("sequenceId", 1).withValue("stepNumber", 1)
|
||||
.withValue("stepType", CrmSequenceStepType.EMAIL.getId()).withValue("emailTemplateId", 1));
|
||||
put("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("sequenceId", 1).withValue("contactId", 1)
|
||||
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("currentStepNumber", 0).withValue("failureCount", 0)
|
||||
.withValue("nextStepDate", Instant.now().minusSeconds(60)));
|
||||
if(eligible)
|
||||
{
|
||||
put("crm_consent_record", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("consentType", CrmConsentType.EMAIL.getId())
|
||||
.withValue("status", CrmConsentStatus.GRANTED.getId()).withValue("consentDate", Instant.now()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport-linked message fixture.
|
||||
*******************************************************************************/
|
||||
private void message(int id, CrmEmailStatus status, Instant created) throws QException
|
||||
{
|
||||
put("crm_email_message", new QRecord().withValue("id", id).withValue("contactId", id).withValue("subject", "Test")
|
||||
.withValue("idempotencyKey", "key:" + id).withValue("status", status.getId()).withValue("createDate", created));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Score rule fixture.
|
||||
*******************************************************************************/
|
||||
private void rule(int id, String field, CrmScoreOperator operator, String expected, int points) throws QException
|
||||
{
|
||||
put("crm_lead_score_rule", new QRecord().withValue("id", id).withValue("name", "Rule " + id).withValue("fieldPath", field)
|
||||
.withValue("operator", operator.getId()).withValue("fieldValue", expected).withValue("scoreAdjustment", points).withValue("isActive", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert the activity's stable code.
|
||||
*******************************************************************************/
|
||||
private void assertActivity(String code) throws QException
|
||||
{
|
||||
QRecord activity = rows("crm_activity").getFirst();
|
||||
assertThat(GetAction.execute("crm_activity_type", activity.getValue("activityTypeId")).getValueString("code")).isEqualTo(code);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert isolated fixture data.
|
||||
*******************************************************************************/
|
||||
private void put(String table, QRecord row) throws QException
|
||||
{
|
||||
if(row.getValue("id") != null && GetAction.execute(table, row.getValue("id")) != null)
|
||||
{
|
||||
MemoryRecordStore.getInstance().update(new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(table).withRecord(row), true);
|
||||
}
|
||||
else
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(row), true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query in the current session.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fake transport with independent send and delivery controls.
|
||||
*******************************************************************************/
|
||||
private static class FakeTransport extends CrmEmailTransport
|
||||
{
|
||||
private boolean ready = true;
|
||||
private boolean fail;
|
||||
private boolean marketing;
|
||||
private int sends;
|
||||
private String key;
|
||||
private String subject;
|
||||
private String body;
|
||||
private final Map<String, Delivery> deliveries = new HashMap<>();
|
||||
private final List<String> lookups = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** No external provider is installed.
|
||||
*******************************************************************************/
|
||||
FakeTransport()
|
||||
{
|
||||
super(new CrmQBitConfig());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrmReadiness readiness()
|
||||
{
|
||||
return (ready ? CrmReadiness.QBIT_EMAIL : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
if(fail)
|
||||
{
|
||||
throw (new QException("Fake transport failure"));
|
||||
}
|
||||
sends++;
|
||||
key = "crm:" + purpose + ":" + id;
|
||||
this.subject = subject;
|
||||
this.body = body;
|
||||
this.marketing = marketing;
|
||||
return (new Delivery("provider-1", CrmEmailStatus.QUEUED, null, null, null, null, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Delivery> lookup(String key)
|
||||
{
|
||||
lookups.add(key);
|
||||
return (Optional.ofNullable(deliveries.get(key)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the core dispatch without an alerts backend.
|
||||
*******************************************************************************/
|
||||
public static class Sink extends LoggingAlertSink
|
||||
{
|
||||
private static final List<NotificationSignal> notifications = new ArrayList<>();
|
||||
private static final List<AlertSignal> alerts = new ArrayList<>();
|
||||
private static final List<AlertResolution> resolutions = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
{
|
||||
notifications.add(signal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void raise(AlertSignal signal)
|
||||
{
|
||||
alerts.add(signal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resolve(AlertResolution resolution)
|
||||
{
|
||||
resolutions.add(resolution);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every UI action traverses real ETL Review and Result against persisted data.
|
||||
*******************************************************************************/
|
||||
class CrmActionsTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Cover the complete registered action inventory.
|
||||
*******************************************************************************/
|
||||
static Stream<String> actions()
|
||||
{
|
||||
return (CrmActions.ACTIONS.stream().map(CrmActions.Action::name));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review is read-only; Result describes the actual effect on selected rows.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@MethodSource("actions")
|
||||
void everyActionReviewsAndExecutes(String name) throws Exception
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(name);
|
||||
seed(action);
|
||||
Map<String, Serializable> inputs = inputs();
|
||||
Map<String, String> before = snapshot();
|
||||
RunProcessOutput review = review(name, inputs, List.of(1, 2));
|
||||
assertThat(snapshot()).as("Review must not write any CRM table").isEqualTo(before);
|
||||
List<ProcessSummaryLine> reviewLines = lines(review, false);
|
||||
assertThat(reviewLines).isNotEmpty();
|
||||
assertThat(reviewLines).allSatisfy(line ->
|
||||
{
|
||||
assertThat(line.getSingularFutureMessage()).isNotBlank();
|
||||
assertThat(line.getPluralFutureMessage()).isNotBlank();
|
||||
assertThat(line.getSingularPastMessage()).isNotBlank();
|
||||
assertThat(line.getPluralPastMessage()).isNotBlank();
|
||||
assertThat(line.getPrimaryKeys()).isNotEmpty();
|
||||
});
|
||||
RunProcessOutput result = resume(name, review, Map.of());
|
||||
assertThat(result.getProcessState().getNextStepName()).hasValue("result");
|
||||
List<ProcessSummaryLine> resultLines = lines(result, true);
|
||||
assertThat(resultLines).isNotEmpty();
|
||||
assertThat(resultLines).noneMatch(line -> line.getStatus() == Status.ERROR);
|
||||
if(action.operation().equals("email"))
|
||||
{
|
||||
assertThat(resultLines).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
assertThat(resultLines.getFirst().getSingularPastMessage()).isNotBlank();
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(reviewLines).anyMatch(line -> line.getStatus() == Status.OK);
|
||||
assertThat(resultLines).anyMatch(line -> line.getStatus() == Status.OK);
|
||||
verifyEffect(action, resultLines);
|
||||
}
|
||||
if(!List.of("merge", "convert").contains(action.operation()))
|
||||
{
|
||||
assertThat(GetAction.execute(action.table(), 3).getValueString("ownerUserId")).isEqualTo("original");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bindings, input screens, permissions, selection limits and removed processes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataIsComplete()
|
||||
{
|
||||
assertThat(CrmActions.ACTIONS).hasSize(28);
|
||||
assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(32);
|
||||
for(CrmActions.Action action : CrmActions.ACTIONS)
|
||||
{
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(action.name());
|
||||
assertThat(process.getTableName()).isEqualTo(action.table());
|
||||
assertThat(process.getMinInputRecords()).isPositive();
|
||||
assertThat(process.getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
assertThat(process.getFrontendStep("review")).isNotNull();
|
||||
assertThat(process.getFrontendStep("result")).isNotNull();
|
||||
if(!CrmActions.inputFields(action).isEmpty())
|
||||
{
|
||||
assertThat(process.getFrontendStep("details")).isNotNull();
|
||||
assertThat(process.getStepList().indexOf(process.getFrontendStep("details")))
|
||||
.isLessThan(process.getStepList().indexOf(process.getFrontendStep("review")));
|
||||
}
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
assertThat(process.getMinInputRecords()).isEqualTo(2);
|
||||
assertThat(process.getMaxInputRecords()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores", "crm_sync_email_delivery", "crm_expire_consents"))
|
||||
{
|
||||
assertThat(QContext.getQInstance().getProcess(name).getTableName()).isNull();
|
||||
}
|
||||
assertThat(QContext.getQInstance().getProcesses()).doesNotContainKeys("crm_fire_reminders", "crm_process_overdue_tasks", "crm_import_contacts", "crm_track_email_event");
|
||||
assertThat(QContext.getQInstance().getProcess("crm_move_deal_stage").getInputField("targetStageId").orElseThrow().getPossibleValueSourceFilter()).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative and management actions have distinct permission grants.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionBoundaries() throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput("crm_contact");
|
||||
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE));
|
||||
for(String name : List.of("crm_bulk_assign_owner", "crm_assign_company_owner", "crm_transfer_ownership", "crm_merge_contacts", "crm_merge_companies",
|
||||
"crm_subscribe_to_list", "crm_enroll_in_sequence", "crm_activate_sequence", "crm_pause_sequence", "crm_gdpr_anonymize_contact"))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasProcessPermission(input, name)).as(name).isFalse();
|
||||
}
|
||||
assertThat(PermissionsHelper.hasProcessPermission(input, "crm_log_activity")).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No writes when the selected target belongs to another pipeline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void crossPipelineIsWarningInReviewAndResult() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_move_deal_stage"));
|
||||
Map<String, Serializable> values = inputs();
|
||||
values.put("targetStageId", 5);
|
||||
RunProcessOutput review = review("crm_move_deal_stage", values, List.of(1, 2));
|
||||
assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
RunProcessOutput result = resume("crm_move_deal_stage", review, Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
assertThat(GetAction.execute("crm_deal", 1).getValueInteger("pipelineStageId")).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing input is not silently accepted through the process API.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingOwnerIsWarning() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_bulk_assign_owner"));
|
||||
RunProcessOutput review = review("crm_bulk_assign_owner", Map.of(), List.of(1, 2));
|
||||
assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
RunProcessOutput result = resume("crm_bulk_assign_owner", review, Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("original");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only users cannot mutate even if they can see the process.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readOnlySelectionIsSkipped() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_log_activity"));
|
||||
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.MANAGE_READ));
|
||||
RunProcessOutput review = review("crm_log_activity", inputs(), List.of(1));
|
||||
assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
RunProcessOutput result = resume("crm_log_activity", review, Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
assertThat(rows("crm_activity")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** API callers cannot merge one/three records or select an unrelated survivor.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mergeRequiresExactlyTwoSelectedRecords() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_merge_contacts"));
|
||||
Map<String, String> before = snapshot();
|
||||
for(List<Integer> ids : List.of(List.of(1), List.of(1, 2, 3)))
|
||||
{
|
||||
assertThatThrownBy(() -> review("crm_merge_contacts", inputs(), ids)).isInstanceOf(QException.class)
|
||||
.hasMessageContaining("exactly two");
|
||||
}
|
||||
Map<String, Serializable> values = inputs();
|
||||
values.put("primaryId", 3);
|
||||
assertThatThrownBy(() -> review("crm_merge_contacts", values, List.of(1, 2))).isInstanceOf(QException.class)
|
||||
.hasMessageContaining("one of the two selected");
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing selections cannot accidentally process an entire table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void selectionIsRequired() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_auto_assign"));
|
||||
assertThatThrownBy(() -> new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_auto_assign")))
|
||||
.isInstanceOf(QException.class).hasMessageContaining("Select records");
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("original");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A failure on one row is reported as ERROR while other selected rows complete.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resultReportsRejectedUpdates() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_assign_company_owner"));
|
||||
RunProcessOutput review = review("crm_assign_company_owner", inputs(), List.of(1, 2));
|
||||
assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.OK);
|
||||
QContext.getQInstance().getTable("crm_company").withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(RejectFirstUpdate.class));
|
||||
RunProcessOutput result = resume("crm_assign_company_owner", review, Map.of());
|
||||
assertThat(lines(result, true)).anySatisfy(line ->
|
||||
{
|
||||
assertThat(line.getStatus()).isEqualTo(Status.ERROR);
|
||||
assertThat(line.getPrimaryKeys()).containsExactly(1);
|
||||
assertThat(line.getSingularPastMessage()).contains("rejected");
|
||||
});
|
||||
assertThat(lines(result, true)).anySatisfy(line ->
|
||||
{
|
||||
assertThat(line.getStatus()).isEqualTo(Status.OK);
|
||||
assertThat(line.getPrimaryKeys()).containsExactly(2);
|
||||
});
|
||||
assertThat(GetAction.execute("crm_company", 1).getValueString("ownerUserId")).isEqualTo("original");
|
||||
assertThat(GetAction.execute("crm_company", 2).getValueString("ownerUserId")).isEqualTo("new-owner");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test-only backend rejection after the user has reviewed the selection.
|
||||
*******************************************************************************/
|
||||
public static class RejectFirstUpdate extends AbstractPreUpdateCustomizer
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
records.stream().filter(row -> Integer.valueOf(1).equals(row.getValueInteger("id")))
|
||||
.forEach(row -> row.addError(new BadInputStatusMessage("Update rejected for testing")));
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Revalidate consent-related suppression changed after the Review screen.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void enrollmentRechecksSuppressionAfterReview() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_enroll_in_sequence"));
|
||||
RunProcessOutput review = review("crm_enroll_in_sequence", inputs(), List.of(1, 2));
|
||||
new com.kingsrook.qqq.backend.core.actions.tables.UpdateAction().execute(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput("crm_contact")
|
||||
.withRecord(new QRecord().withValue("id", 1).withValue("doNotEmail", true)));
|
||||
RunProcessOutput result = resume("crm_enroll_in_sequence", review, Map.of());
|
||||
assertThat(lines(result, true)).anyMatch(line -> line.getStatus() == Status.WARNING && line.getPrimaryKeys().equals(List.of(1)));
|
||||
assertThat(rows("crm_sequence_enrollment")).extracting(row -> row.getValueInteger("contactId")).containsExactly(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required stage fields are checked before any writes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stageRequiredFieldIsReviewed() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_move_deal_stage"));
|
||||
change("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Needs company")
|
||||
.withValue("requiredFieldsJson", "[\"companyId\"]"));
|
||||
RunProcessOutput review = review("crm_move_deal_stage", inputs(), List.of(1, 2));
|
||||
assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
RunProcessOutput result = resume("crm_move_deal_stage", review, Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
assertThat(rows("crm_deal_stage_history")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid configuration produces standard ERROR lines on both screens.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validationErrorsAppearInReviewAndResult() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_move_deal_stage"));
|
||||
change("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("requiredFieldsJson", "invalid json"));
|
||||
RunProcessOutput review = review("crm_move_deal_stage", inputs(), List.of(1, 2));
|
||||
assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.ERROR);
|
||||
RunProcessOutput result = resume("crm_move_deal_stage", review, Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.ERROR);
|
||||
assertThat(rows("crm_deal_stage_history")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Request values cannot redirect an action to another table or record id.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void installedBindingOwnsTheSelection() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_bulk_assign_owner"));
|
||||
Map<String, Serializable> values = inputs();
|
||||
values.put("sourceTable", "crm_company");
|
||||
values.put("destinationTable", "crm_company");
|
||||
values.put("recordId", 3);
|
||||
values.put("contactId", 3);
|
||||
RunProcessOutput result = resume("crm_bulk_assign_owner", review("crm_bulk_assign_owner", values, List.of(1, 2)), Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.OK);
|
||||
assertThat(rows("crm_company")).allMatch(row -> "original".equals(row.getValueString("ownerUserId")));
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("new-owner");
|
||||
assertThat(GetAction.execute("crm_contact", 3).getValueString("ownerUserId")).isEqualTo("original");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No rules produces an honest warning instead of a successful assignment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void autoAssignNoMatchIsWarning() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_auto_assign"));
|
||||
change("crm_assignment_rule", new QRecord().withValue("id", 1).withValue("isActive", false));
|
||||
RunProcessOutput result = resume("crm_auto_assign", review("crm_auto_assign", inputs(), List.of(1, 2)), Map.of());
|
||||
assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING);
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("original");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scoring crosses two full keyset pages, including non-contiguous ids.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringPagesInBatchesOf200() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_recalculate_score"));
|
||||
for(int i = 4; i <= 405; i++)
|
||||
{
|
||||
put("crm_contact", new QRecord().withValue("id", 10 + i * 2).withValue("lastName", "Contact").withValue("ownerUserId", "original"));
|
||||
}
|
||||
PageObserver.sizes.clear();
|
||||
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);
|
||||
assertThat(PageObserver.sizes).containsExactly(200, 200, 5);
|
||||
QContext.getQInstance().getTable("crm_contact").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
assertThat(rows("crm_contact")).hasSize(405).allMatch(row -> row.getValueInteger("leadScore") == 10);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Changing enrollment status cannot shift an offset and skip the next page.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sequencePagesInBatchesOf200() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_enroll_in_sequence"));
|
||||
for(int i = 1; i <= 405; i++)
|
||||
{
|
||||
put("crm_sequence_enrollment", new QRecord().withValue("id", i * 2).withValue("sequenceId", 1).withValue("contactId", 1)
|
||||
.withValue("status", 1).withValue("currentStepNumber", 0).withValue("failureCount", 0).withValue("nextStepDate", Instant.now().minusSeconds(60)));
|
||||
}
|
||||
put("crm_sequence_enrollment", new QRecord().withValue("id", 9999).withValue("sequenceId", 1).withValue("contactId", 1)
|
||||
.withValue("status", 1).withValue("currentStepNumber", 0).withValue("nextStepDate", Instant.now().plusSeconds(86400)));
|
||||
PageObserver.sizes.clear();
|
||||
QContext.getQInstance().getTable("crm_sequence_enrollment").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class));
|
||||
RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_process_sequence_steps"));
|
||||
assertThat(result.getValues().get("processedCount")).isEqualTo(405);
|
||||
assertThat(PageObserver.sizes).containsExactly(200, 200, 5);
|
||||
QContext.getQInstance().getTable("crm_sequence_enrollment").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
assertThat(rows("crm_sequence_enrollment").stream().filter(row -> row.getValueInteger("id") != 9999))
|
||||
.hasSize(405).allMatch(row -> row.getValueInteger("status") == 3);
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 9999).getValueInteger("status")).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Observe the actual query bounds, not just the final job counts.
|
||||
*******************************************************************************/
|
||||
public static class PageObserver extends AbstractPostQueryCustomizer
|
||||
{
|
||||
private static final List<Integer> sizes = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
if(input instanceof QueryInput query && query.getFilter().getOrderBys() != null && !query.getFilter().getOrderBys().isEmpty())
|
||||
{
|
||||
assertThat(query.getFilter().getLimit()).isEqualTo(200);
|
||||
assertThat(query.getFilter().getOrderBys().getFirst().getFieldName()).isEqualTo("id");
|
||||
sizes.add(records.size());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify observable writes, including merge relationships and duplicate links.
|
||||
*******************************************************************************/
|
||||
private void verifyEffect(CrmActions.Action action, List<ProcessSummaryLine> summary) throws QException
|
||||
{
|
||||
QRecord first = GetAction.execute(action.table(), 1);
|
||||
switch(action.operation())
|
||||
{
|
||||
case "assign" -> assertThat(first.getValueString("ownerUserId")).isEqualTo("new-owner");
|
||||
case "auto" -> assertThat(first.getValueString("ownerUserId")).isEqualTo("auto-owner");
|
||||
case "subscribe" -> assertThat(rows("crm_email_list_member")).hasSize(2);
|
||||
case "unsubscribe" -> assertThat(rows("crm_email_list_member")).allMatch(row -> row.getValueInstant("unsubscribedDate") != null);
|
||||
case "enroll" -> assertThat(rows("crm_sequence_enrollment")).hasSize(2);
|
||||
case "unenroll" -> assertThat(rows("crm_sequence_enrollment")).allMatch(row -> row.getValueInstant("unenrolledDate") != null);
|
||||
case "activity" ->
|
||||
{
|
||||
assertThat(rows("crm_activity")).hasSize(2);
|
||||
String foreignKey = action.table().substring(4) + "Id";
|
||||
assertThat(rows("crm_activity")).extracting(row -> row.getValueInteger(foreignKey)).containsExactlyInAnyOrder(1, 2);
|
||||
}
|
||||
case "score" -> assertThat(first.getValueInteger("leadScore")).isEqualTo(10);
|
||||
case "merge" ->
|
||||
{
|
||||
assertThat(GetAction.execute(action.table(), 2)).isNull();
|
||||
assertThat(GetAction.execute(action.table(), 3)).isNotNull();
|
||||
}
|
||||
case "duplicates" ->
|
||||
{
|
||||
assertThat(summary).anySatisfy(line ->
|
||||
{
|
||||
assertThat(line.getStatus()).isEqualTo(Status.WARNING);
|
||||
assertThat(line.getPrimaryKeys()).containsExactlyInAnyOrder(1, 2);
|
||||
});
|
||||
assertThat(rows(action.table())).hasSize(3);
|
||||
}
|
||||
case "createDeal", "clone" -> assertThat(rows("crm_deal")).hasSize(5);
|
||||
case "erase" ->
|
||||
{
|
||||
assertThat(first.getValueBoolean("doNotEmail")).isTrue();
|
||||
assertThat(first.getValueString("email")).endsWith("@removed.invalid");
|
||||
}
|
||||
case "stage" -> assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(2);
|
||||
case "won" ->
|
||||
{
|
||||
assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(3);
|
||||
assertThat(first.getValueInteger("winLossReasonId")).isEqualTo(1);
|
||||
assertThat(first.getValueLocalDate("actualCloseDate")).isNotNull();
|
||||
assertThat(rows("crm_deal_stage_history")).hasSize(2);
|
||||
}
|
||||
case "lost" -> assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(4);
|
||||
case "reopen" ->
|
||||
{
|
||||
assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(2);
|
||||
assertThat(first.getValue("actualCloseDate")).isNull();
|
||||
assertThat(first.getValue("winLossReasonId")).isNull();
|
||||
}
|
||||
case "addContact" -> assertThat(rows("crm_deal_contact")).hasSize(2);
|
||||
case "convert" ->
|
||||
{
|
||||
assertThat(first.getValueInteger("contactId")).isNotNull();
|
||||
assertThat(GetAction.execute("crm_form_submission", 2).getValueInteger("contactId")).isNotNull();
|
||||
assertThat(rows("crm_contact")).hasSize(5);
|
||||
}
|
||||
case "activate" -> assertThat(first.getValueBoolean("isActive")).isTrue();
|
||||
case "pause" -> assertThat(first.getValueBoolean("isActive")).isFalse();
|
||||
default -> throw (new AssertionError(action.name()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Start, complete input details, and stop at the actual standard Review screen.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput review(String name, Map<String, Serializable> values, List<Integer> ids) throws QException
|
||||
{
|
||||
RunProcessInput request = new RunProcessInput();
|
||||
request.setProcessName(name);
|
||||
request.setValues(new HashMap<>(values));
|
||||
request.addValue("queryFilterJson", JsonUtils.toJson(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids))));
|
||||
RunProcessOutput output = new RunProcessAction().execute(request);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
if(output.getProcessState().getNextStepName().orElse("").equals("details"))
|
||||
{
|
||||
output = resume(name, output, values);
|
||||
}
|
||||
assertThat(output.getProcessState().getNextStepName()).hasValue("review");
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resume the process just as the frontend submits its current screen.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput resume(String name, RunProcessOutput prior, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
RunProcessInput request = new RunProcessInput();
|
||||
request.setProcessName(name);
|
||||
request.setProcessUUID(prior.getProcessUUID());
|
||||
request.setStartAfterStep(prior.getProcessState().getNextStepName().orElseThrow());
|
||||
request.setValues(new HashMap<>(values));
|
||||
RunProcessOutput output = new RunProcessAction().execute(request);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read standard ETL summaries, not test-specific step state.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> lines(RunProcessOutput output, boolean result)
|
||||
{
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply all action inputs; each operation accepts only its declared fields.
|
||||
*******************************************************************************/
|
||||
private Map<String, Serializable> inputs()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put("newOwnerUserId", "new-owner");
|
||||
values.put("emailListId", 1);
|
||||
values.put("sequenceId", 1);
|
||||
values.put("primaryId", 1);
|
||||
values.put("pipelineId", 1);
|
||||
values.put("targetStageId", 2);
|
||||
values.put("winLossReasonId", 1);
|
||||
values.put("dealName", "New deal");
|
||||
values.put("dealAmount", new BigDecimal("250"));
|
||||
values.put("contactId", 1);
|
||||
values.put("activityTypeId", 1);
|
||||
values.put("subject", "Phone conversation");
|
||||
values.put("description", "Discussed next steps");
|
||||
return (values);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable persisted fixtures, including a third record excluded from selections.
|
||||
*******************************************************************************/
|
||||
private void seed(CrmActions.Action action) throws QException
|
||||
{
|
||||
put("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
|
||||
put("crm_pipeline", new QRecord().withValue("id", 2).withValue("name", "Other"));
|
||||
for(int id = 1; id <= 5; id++)
|
||||
{
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", id).withValue("name", "Stage " + id).withValue("pipelineId", id == 5 ? 2 : 1)
|
||||
.withValue("sortOrder", id).withValue("probabilityPct", id == 3 ? 100 : 50).withValue("isClosedWon", id == 3).withValue("isClosedLost", id == 4));
|
||||
}
|
||||
put("crm_win_loss_reason", new QRecord().withValue("id", 1).withValue("name", "Budget"));
|
||||
put("crm_activity_type", new QRecord().withValue("id", 1).withValue("name", "Note"));
|
||||
put("crm_email_list", new QRecord().withValue("id", 1).withValue("name", "Newsletter").withValue("isActive", true));
|
||||
put("crm_assignment_rule", new QRecord().withValue("id", 1).withValue("name", "Assign").withValue("entityType", 1)
|
||||
.withValue("assignStrategy", 1).withValue("assignToUserId", "auto-owner").withValue("isActive", true).withValue("sortOrder", 1));
|
||||
put("crm_lead_score_rule", new QRecord().withValue("id", 1).withValue("name", "Name present").withValue("fieldPath", "lastName")
|
||||
.withValue("operator", 6).withValue("scoreAdjustment", 10).withValue("isActive", true).withValue("sortOrder", 1));
|
||||
for(int id = 1; id <= 3; id++)
|
||||
{
|
||||
String email = action.operation().equals("duplicates") && id < 3 ? (id == 1 ? "A@EXAMPLE.COM" : "a@example.com") : "person" + id + "@example.com";
|
||||
put("crm_contact", new QRecord().withValue("id", id).withValue("firstName", "Person").withValue("lastName", "Contact " + id)
|
||||
.withValue("email", email).withValue("ownerUserId", "original").withValue("leadScore", 0));
|
||||
put("crm_company", new QRecord().withValue("id", id).withValue("name", "Company " + id).withValue("ownerUserId", "original")
|
||||
.withValue("domain", id < 3 ? (id == 1 ? "EXAMPLE.COM" : "example.com") : "different.com"));
|
||||
put("crm_deal", new QRecord().withValue("id", id).withValue("name", "Deal " + id).withValue("pipelineId", 1)
|
||||
.withValue("pipelineStageId", action.operation().equals("reopen") ? 3 : 1).withValue("amount", new BigDecimal("100"))
|
||||
.withValue("currencyCode", "USD").withValue("ownerUserId", "original").withValue("stageEnteredDate", Instant.now().minusSeconds(86400)));
|
||||
put("crm_form_submission", new QRecord().withValue("id", id).withValue("email", "form" + id + "@example.com")
|
||||
.withValue("firstName", "Web").withValue("lastName", "Lead " + id).withValue("ownerUserId", "original"));
|
||||
put("crm_email_sequence", new QRecord().withValue("id", id).withValue("name", "Sequence " + id)
|
||||
.withValue("isActive", !action.operation().equals("activate")).withValue("ownerUserId", "original"));
|
||||
if(id <= 2 && action.operation().equals("unsubscribe"))
|
||||
{
|
||||
put("crm_email_list_member", new QRecord().withValue("id", id).withValue("emailListId", 1).withValue("contactId", id)
|
||||
.withValue("email", email).withValue("status", 1));
|
||||
}
|
||||
if(id <= 2 && action.operation().equals("unenroll"))
|
||||
{
|
||||
put("crm_sequence_enrollment", new QRecord().withValue("id", id).withValue("sequenceId", 1).withValue("contactId", id)
|
||||
.withValue("status", 1).withValue("currentStepNumber", 0).withValue("failureCount", 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw fixtures intentionally bypass insert automation and uniqueness for duplicates.
|
||||
*******************************************************************************/
|
||||
private void put(String table, QRecord row) throws QException
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(row), true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Update an existing fixture through normal DML.
|
||||
*******************************************************************************/
|
||||
private void change(String table, QRecord row) throws QException
|
||||
{
|
||||
new com.kingsrook.qqq.backend.core.actions.tables.UpdateAction().execute(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(table).withRecord(row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query through the normal session.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture persisted values in every table to detect accidental Review writes.
|
||||
*******************************************************************************/
|
||||
private Map<String, String> snapshot() throws QException
|
||||
{
|
||||
Map<String, String> snapshot = new TreeMap<>();
|
||||
for(String table : QContext.getQInstance().getTables().keySet())
|
||||
{
|
||||
List<Map<String, Serializable>> values = new ArrayList<>();
|
||||
rows(table).forEach(row -> values.add(new TreeMap<>(row.getValues())));
|
||||
snapshot.put(table, JsonUtils.toJson(values));
|
||||
}
|
||||
return (snapshot);
|
||||
}
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for FireRemindersProcess.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for firing reminders: create activity with reminder, run process,
|
||||
** verify the reminder flag is cleared.
|
||||
*******************************************************************************/
|
||||
class FireRemindersProcessTest extends BaseTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an activity with a due reminder and verify it gets cleared.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFireReminders() throws QException
|
||||
{
|
||||
Integer typeId = insertActivityType("Meeting");
|
||||
|
||||
///////////////////////////////////////
|
||||
// create activity with past reminder //
|
||||
///////////////////////////////////////
|
||||
InsertOutput activityInsert = new InsertAction().execute(
|
||||
new InsertInput(Activity.TABLE_NAME).withRecordEntity(
|
||||
new Activity()
|
||||
.withActivityTypeId(typeId)
|
||||
.withSubject("Team Meeting")
|
||||
.withIsReminderSet(true)
|
||||
.withReminderDate(Instant.now().minus(1, ChronoUnit.HOURS))
|
||||
.withIsCompleted(false)
|
||||
.withOwnerUserId("user-001")));
|
||||
Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id");
|
||||
|
||||
////////////////////////////////////
|
||||
// run the process //
|
||||
////////////////////////////////////
|
||||
RunProcessInput processInput = new RunProcessInput();
|
||||
processInput.setProcessName(FireRemindersProcess.NAME);
|
||||
|
||||
RunProcessOutput processOutput = new RunProcessAction().execute(processInput);
|
||||
|
||||
assertEquals(1, processOutput.getValues().get("firedCount"));
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// verify the reminder flag was cleared //
|
||||
/////////////////////////////////////////////
|
||||
Activity updated = new Activity(GetAction.execute(Activity.TABLE_NAME, activityId));
|
||||
assertFalse(updated.getIsReminderSet());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Future reminders should not be fired.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFutureReminderNotFired() throws QException
|
||||
{
|
||||
Integer typeId = insertActivityType("Call");
|
||||
|
||||
new InsertAction().execute(
|
||||
new InsertInput(Activity.TABLE_NAME).withRecordEntity(
|
||||
new Activity()
|
||||
.withActivityTypeId(typeId)
|
||||
.withSubject("Future Call")
|
||||
.withIsReminderSet(true)
|
||||
.withReminderDate(Instant.now().plus(1, ChronoUnit.DAYS))
|
||||
.withIsCompleted(false)
|
||||
.withOwnerUserId("user-001")));
|
||||
|
||||
RunProcessInput processInput = new RunProcessInput();
|
||||
processInput.setProcessName(FireRemindersProcess.NAME);
|
||||
|
||||
RunProcessOutput processOutput = new RunProcessAction().execute(processInput);
|
||||
|
||||
assertEquals(0, processOutput.getValues().get("firedCount"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Helper: insert an ActivityType and return its id.
|
||||
***************************************************************************/
|
||||
private Integer insertActivityType(String name) throws QException
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(
|
||||
new InsertInput(ActivityType.TABLE_NAME)
|
||||
.withRecordEntity(new ActivityType()
|
||||
.withName(name)
|
||||
.withIconName("alarm")
|
||||
.withIsSystem(false)
|
||||
.withSortOrder(1)
|
||||
.withIsActive(true)));
|
||||
return (output.getRecords().get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user