Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e361a9d133
|
No files matched your search
+57
-11
@@ -1,14 +1,60 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
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
|
||||
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
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+$/]
|
||||
@@ -1,7 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -1,23 +0,0 @@
|
||||
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,74 +1,26 @@
|
||||
# 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: Plan a cut without changing release state
|
||||
description: Print the plan without merging or creating branches
|
||||
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
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
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 || 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 || '' }}
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
+2
-3
@@ -2,9 +2,8 @@
|
||||
|
||||
## Unreleased / 0.2.0
|
||||
|
||||
- Register CRM health checks (`crm.email`, `crm.sequence_lag`, `crm.lead_score`) with the QQQ 4.1 core health registry (`HealthChecksMetaData`, qqq#1199), replacing the `qqq-middleware-health` indicator stand-in and its optional dependency.
|
||||
|
||||
- Lead scoring keeps its incremental watermark at millisecond precision, matching `modifyDate`, so a contact changed in the same millisecond a pass starts is not skipped.
|
||||
- Add explicit shared email-template opt-in using qbit-email 0.1.0-RC.10. Email owns rendering and checked outbox handoff; CRM retains caller-visible variables, delivery categories and exact sent-subject history. Legacy template metadata is omitted only for opted-in hosts; tenant hosts retain the existing path.
|
||||
- Adopt QQQ 4.1.0-RC.20 and register native CRM health checks while preserving legacy health adapters and caller/tenant context.
|
||||
|
||||
- Read the CRM QBit metadata version from the Maven-filtered artifact version so release metadata matches the build.
|
||||
|
||||
|
||||
@@ -62,9 +62,9 @@ qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
- `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields.
|
||||
- `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads.
|
||||
- `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables.
|
||||
- Health: the producer registers `crm.email`, `crm.sequence_lag` and `crm.lead_score` with the core health registry (`HealthChecksMetaData`). Hosts that enable core health monitoring (`HealthMetaDataProvider`) get them scheduled by the core `HealthRunner`, which runs each check as the core system user. `CrmHealthChecks.run()` evaluates them once in the caller's context. No check adds permissions or tenant keys or returns personal values.
|
||||
- `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.
|
||||
|
||||
Help and health both use the core registries (`HelpBundles`, `HealthChecksMetaData`). See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
|
||||
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 |
|
||||
|---|---|
|
||||
|
||||
+13
@@ -1,5 +1,18 @@
|
||||
# Upgrading
|
||||
|
||||
## Shared email templates (Unreleased / 0.2.0)
|
||||
|
||||
This version uses public qbit-email `0.1.0-RC.10` and QQQ `4.1.0-RC.20`. Shared templates remain disabled by default. Existing optional-email and tenant-enabled hosts retain their legacy template behavior.
|
||||
|
||||
For a non-tenant host adopting shared templates:
|
||||
|
||||
1. Install the email qbit and configure its template permission base as `crm.manage`. Existing per-template edit permissions and record locks remain enforced; this grant does not expose other email settings.
|
||||
2. Before enabling `CrmQBitConfig.withSharedEmailTemplates(true)`, migrate legacy template content into email-owned `CRM_LEGACY` templates and active versions, remap sequence/history and generic record references, and retain source provenance and rollback data. This library does not run a host database migration. Populated deployments require a documented CRM maintenance window with writes and workers quiesced, fresh preflight counts and verified backups.
|
||||
3. Enable the opt-in only after migration. It omits `crm_email_template` metadata and its possible-value source entirely and uses `email_template` for actions and sequences. Tenant mode and missing email installations reject the opt-in.
|
||||
4. Native email templates retain their category and content policies. Migrated compatibility templates preserve historical substitutions and overrides. Checked replay can reject a changed template version or envelope; reconcile the existing effect rather than silently retrying with a new idempotency key.
|
||||
|
||||
The producer also registers `crm.email`, `crm.sequence-lag` and `crm.lead-score` with core health metadata. Existing legacy adapters remain available. Checks use execution-time context, preserve tenant visibility and do not assign tenant keys to contextless workers.
|
||||
|
||||
## Starter defaults (Unreleased / 0.2.0)
|
||||
|
||||
The CRM now ships `db/liquibase/qbit-crm/crm-defaults.changelog.sql`, a separate insert-only changelog. Without it,
|
||||
|
||||
+11
-11
@@ -89,8 +89,8 @@ These parts are real and are kept, adapted to 0.2.0:
|
||||
| Imports | `importContacts` | core bulk load + saved bulk-load profiles |
|
||||
| PII | none | `DataClassification.PII` on every personal field; secured-data tiers apply |
|
||||
| Reporting | 14 bespoke widgets | home widgets for the daily view; trend reports via data analysis (`crm_*.analyze`) |
|
||||
| Health | none | `crm.*` checks in the core health registry (`HealthChecksMetaData`) |
|
||||
| Help | none | `help/qbit-crm.yaml` bundle registered with core `HelpBundles` |
|
||||
| Health | none | `crm` health checks (stand-in until core #1199) |
|
||||
| Help | none | `help/qbit-crm.yaml` bundle (stand-in loader until core #1198) |
|
||||
| Tenancy | clientId lock on 3 tables | configurable tenant key type on every table; the host maps Authentik claims to keys |
|
||||
|
||||
## Data model (33 tables: 6 main, 15 settings, 12 sub-tables)
|
||||
@@ -268,14 +268,13 @@ signals are logged.
|
||||
|
||||
### Health
|
||||
|
||||
`CrmHealthChecks` registers three checks with the core health registry
|
||||
(`HealthChecksMetaData`, component `crm`):
|
||||
- `crm.email`: email transport readiness
|
||||
- `crm.sequence_lag`: the oldest due enrollment more than 30 min late
|
||||
- `crm.lead_score`: the scoring job's last success time
|
||||
`CrmHealthChecks` reports three checks:
|
||||
- email transport readiness
|
||||
- sequence lag (the oldest due enrollment more than 30 min late)
|
||||
- the scoring job's last success time
|
||||
|
||||
The core `HealthRunner` schedules them on hosts that enable health monitoring and runs each one
|
||||
as the core system user. A failing check reports UNKNOWN with a fixed message.
|
||||
It is a `STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)` adapter onto the
|
||||
`qqq-middleware-health` `HealthIndicator` until the central `HealthCheck` SPI ships.
|
||||
|
||||
### Help
|
||||
|
||||
@@ -283,8 +282,9 @@ as the core system user. A failing check reports UNKNOWN with a fixed message.
|
||||
- the app
|
||||
- every table, field, action and possible-value source
|
||||
|
||||
The producer registers it with core `HelpBundles.register`; `HelpBundleAssertions` checks that it
|
||||
is complete.
|
||||
`HelpBundleAssertions` checks that it is complete. Until `HelpBundles.register` is in a core RC,
|
||||
`CrmHelpBundleStandIn` (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)`) loads the same
|
||||
file into `QHelpContent`.
|
||||
|
||||
### Contingent dropdowns
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.2.0-SNAPSHOT</revision>
|
||||
<qbit-email.version>0.1.0-RC.10</qbit-email.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -34,7 +34,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
<version>2.1.2</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -79,11 +79,16 @@
|
||||
<artifactId>qqq-middleware-api</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-health</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>0.1.0-RC.11</version>
|
||||
<version>${qbit-email.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -228,14 +233,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.7 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.20 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.26</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.20</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -67,14 +67,15 @@ public final class CrmAppMetaData
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData build(MetaDataProducerMultiOutput output, String source)
|
||||
{
|
||||
List<String> settings = SETTINGS_TABLES.stream().filter(name -> output.get(QTableMetaData.class, name) != null).toList();
|
||||
QAppMetaData app = new QAppMetaData().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
|
||||
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
|
||||
.withWidgets(new ArrayList<>(CrmHomeWidgets.HOME))
|
||||
.withSection(mainSection())
|
||||
.withSection(new QAppSection().withName("crm_settings").withLabel("Settings").withIcon(new QIcon("settings"))
|
||||
.withTables(new ArrayList<>(SETTINGS_TABLES)));
|
||||
.withTables(new ArrayList<>(settings)));
|
||||
List<String> tables = new ArrayList<>(MAIN_TABLES);
|
||||
tables.addAll(SETTINGS_TABLES);
|
||||
tables.addAll(settings);
|
||||
for(String name : tables)
|
||||
{
|
||||
app.withChild(output.get(QTableMetaData.class, name));
|
||||
|
||||
@@ -41,6 +41,7 @@ public class CrmQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
|
||||
|
||||
private boolean sharedEmailTemplates;
|
||||
private String tenantSecurityKeyType;
|
||||
private String tenantAllAccessKeyName;
|
||||
private String apiName;
|
||||
@@ -363,4 +364,22 @@ public class CrmQBitConfig implements QBitConfig
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in only after the host has migrated legacy template references.
|
||||
*******************************************************************************/
|
||||
public boolean getSharedEmailTemplates()
|
||||
{
|
||||
return (sharedEmailTemplates);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Other hosts keep the legacy optional-email and tenant behavior by default.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withSharedEmailTemplates(boolean sharedEmailTemplates)
|
||||
{
|
||||
this.sharedEmailTemplates = sharedEmailTemplates;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.health.CrmHealthChecks;
|
||||
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
|
||||
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
@@ -73,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
@@ -152,7 +152,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig);
|
||||
CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig);
|
||||
CrmHealthChecks.register(qInstance, qBitConfig);
|
||||
for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
|
||||
{
|
||||
widget.setName(snakeCase(widget.getName()));
|
||||
@@ -393,10 +392,28 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
if(qBitConfig.getSharedEmailTemplates() && (qBitConfig.getTenantSecurityKeyType() != null || qInstance.getTable("email_template") == null))
|
||||
{
|
||||
throw (new QException("Shared CRM templates require installed qbit-email and a non-tenant CRM host"));
|
||||
}
|
||||
MetaDataProducerMultiOutput produced = QBitMetaDataProducer.super.produce(qInstance);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
for(MetaDataProducerOutput item : produced.getEach(MetaDataProducerOutput.class))
|
||||
{
|
||||
if(qBitConfig.getSharedEmailTemplates() && ((item instanceof QTableMetaData table && "crm_email_template".equals(table.getName()))
|
||||
|| (item instanceof QPossibleValueSource source && "crm_email_template".equals(source.getName()))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(qBitConfig.getSharedEmailTemplates() && item instanceof QTableMetaData table && table.getFields().containsKey("emailTemplateId"))
|
||||
{
|
||||
table.getField("emailTemplateId").setPossibleValueSourceName("email_template");
|
||||
}
|
||||
if(qBitConfig.getSharedEmailTemplates() && item instanceof QProcessMetaData process)
|
||||
{
|
||||
process.getInputFields().stream().filter(field -> "emailTemplateId".equals(field.getName()))
|
||||
.forEach(field -> field.setPossibleValueSourceName("email_template"));
|
||||
}
|
||||
output.add(item instanceof QWidgetMetaData widget
|
||||
? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
|
||||
}
|
||||
@@ -418,6 +435,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
|
||||
registerHooks(qInstance);
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
com.kingsrook.qbits.crm.health.CrmHealthChecks.register(qInstance, qBitConfig);
|
||||
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
|
||||
return (output);
|
||||
}
|
||||
|
||||
@@ -375,7 +375,11 @@ public final class CrmAction
|
||||
}
|
||||
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
|
||||
{
|
||||
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
|
||||
if("emailTemplateId".equals(field.getName()) && transport != null && transport.usesSharedTemplates())
|
||||
{
|
||||
transport.templateVariableNames(input.getValueInteger("emailTemplateId"));
|
||||
}
|
||||
else if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
|
||||
{
|
||||
return (field.getLabel() + " is unavailable");
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
@@ -123,6 +126,58 @@ public class CrmEmailTransport
|
||||
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared storage/rendering is explicitly enabled only after host migration.
|
||||
*******************************************************************************/
|
||||
public boolean usesSharedTemplates()
|
||||
{
|
||||
return (config.getSharedEmailTemplates());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only declared inputs are computed and passed to the email-owned renderer.
|
||||
*******************************************************************************/
|
||||
public Set<String> templateVariableNames(Integer templateId) throws QException
|
||||
{
|
||||
return (EmailApi.templateVariableNames(templateId));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep optional email API types behind this adapter boundary.
|
||||
*******************************************************************************/
|
||||
public record TemplateDelivery(String subject, Delivery delivery)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Checked selection, then native outbox rendering retains all send policies.
|
||||
*******************************************************************************/
|
||||
public TemplateDelivery sendTemplateMessage(String purpose, String id, Integer contactId, String address, Integer templateId,
|
||||
String subject, String body, boolean marketing, Map<String, Serializable> variables) throws QException
|
||||
{
|
||||
if(!emailInstalled())
|
||||
{
|
||||
throw (new QUserFacingException("Shared CRM templates require qbit-email"));
|
||||
}
|
||||
String key = "crm:" + purpose + ":" + id;
|
||||
EmailApiRequest request = new EmailApiRequest().withTo(address)
|
||||
.withCategory(marketing ? EmailApiCategory.MARKETING : EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject(subject == null || subject.isBlank() ? null : subject)
|
||||
.withMarkdownBody(body == null || body.isBlank() ? null : body)
|
||||
.withLinkedRecord("crm_contact", contactId).withSender("crm", purpose).withIdempotencyKey(key);
|
||||
variables.forEach(request::withVariable);
|
||||
var checked = EmailApi.sendTemplateOnce(templateId, request, key);
|
||||
var result = checked.effect();
|
||||
if(result.outcome() == EmailApiEffectOutcome.REFUSED)
|
||||
{
|
||||
throw (new QUserFacingException("The email service could not accept this template message."));
|
||||
}
|
||||
var message = result.message();
|
||||
Delivery delivery = message == null ? new Delivery(null, CrmEmailStatus.QUEUED, null, null, null, null, null)
|
||||
: new Delivery(String.valueOf(message.id()), status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null);
|
||||
return (new TemplateDelivery(checked.subject(), delivery));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM-owned delivery snapshot; optional email types never escape this adapter.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -21,8 +21,12 @@
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.api.ActivityKind;
|
||||
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
@@ -81,28 +85,43 @@ public final class CrmEmails
|
||||
recordActivity(existing.getFirst());
|
||||
return;
|
||||
}
|
||||
if(templateId != null)
|
||||
CrmEmailTransport.Delivery delivery;
|
||||
if(templateId != null && transport.usesSharedTemplates())
|
||||
{
|
||||
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");
|
||||
}
|
||||
Set<String> names = new HashSet<>(transport.templateVariableNames(templateId));
|
||||
String overrides = String.valueOf(subject) + body;
|
||||
Set.of("contact.firstName", "contact.lastName", "company.name", "owner.name", "unsubscribeUrl").stream()
|
||||
.filter(name -> overrides.contains("{{" + name + "}}")).forEach(names::add);
|
||||
var sent = transport.sendTemplateMessage(purpose, id, contact.getValueInteger("id"), contact.getValueString("email"),
|
||||
templateId, subject, body, enrollmentId != null, templateVariables(contact, names));
|
||||
subject = sent.subject();
|
||||
delivery = sent.delivery();
|
||||
}
|
||||
if(subject == null || subject.isBlank() || body == null || body.isBlank())
|
||||
else
|
||||
{
|
||||
throw (new QUserFacingException("Email subject and body are required"));
|
||||
if(templateId != null)
|
||||
{
|
||||
QRecord template = GetAction.execute("crm_email_template", templateId);
|
||||
if(template == null)
|
||||
{
|
||||
throw (new QUserFacingException("Email template is unavailable"));
|
||||
}
|
||||
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
|
||||
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
|
||||
if(body == null)
|
||||
{
|
||||
body = template.getValueString("bodyHtml");
|
||||
}
|
||||
}
|
||||
if(subject == null || subject.isBlank() || body == null || body.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Email subject and body are required"));
|
||||
}
|
||||
subject = render(subject, contact);
|
||||
body = render(body, contact);
|
||||
delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
|
||||
contact.getValueString("email"), subject, body, enrollmentId != null);
|
||||
}
|
||||
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)
|
||||
@@ -136,6 +155,29 @@ public final class CrmEmails
|
||||
.withValue("activityId", activities.getFirst().getValue("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Values come from the caller's visible CRM records, never a system query.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Serializable> templateVariables(QRecord contact, Set<String> names) throws QException
|
||||
{
|
||||
QRecord company = !names.contains("company.name") && !names.contains("company") || contact.getValue("companyId") == null
|
||||
? null : GetAction.execute("crm_company", contact.getValue("companyId"));
|
||||
Map<String, Serializable> available = new LinkedHashMap<>();
|
||||
available.put("contact.firstName", value(contact, "firstName"));
|
||||
available.put("contact.lastName", value(contact, "lastName"));
|
||||
available.put("company.name", value(company, "name"));
|
||||
available.put("owner.name", value(contact, "ownerUserId"));
|
||||
available.put("contact", new LinkedHashMap<>(Map.of("firstName", value(contact, "firstName"), "lastName", value(contact, "lastName"))));
|
||||
available.put("company", new LinkedHashMap<>(Map.of("name", value(company, "name"))));
|
||||
available.put("owner", new LinkedHashMap<>(Map.of("name", value(contact, "ownerUserId"))));
|
||||
if(names.contains("unsubscribeUrl"))
|
||||
{
|
||||
available.put("unsubscribeUrl", new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null));
|
||||
}
|
||||
available.keySet().retainAll(names);
|
||||
return (available);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Literal replacements avoid treating customer data as a template program.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.crm.health;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
@@ -33,11 +32,10 @@ import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
@@ -49,25 +47,23 @@ 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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM checks registered with the core health registry (HealthChecksMetaData).
|
||||
** The core HealthRunner schedules them and runs each one in its own automated
|
||||
** system-user context; no check returns personal values.
|
||||
** Core health checks with retained legacy HealthIndicator adapters. Run in the
|
||||
** caller's tenant context when one is supplied, else as the core system user;
|
||||
** no check returns personal values.
|
||||
*******************************************************************************/
|
||||
public class CrmHealthChecks
|
||||
{
|
||||
public static final String EMAIL = "crm.email";
|
||||
public static final String SEQUENCE_LAG = "crm.sequence_lag";
|
||||
public static final String LEAD_SCORE = "crm.lead_score";
|
||||
|
||||
private static final String COMPONENT = "crm";
|
||||
|
||||
private final CrmEmailTransport transport;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same readiness decision as actual email delivery.
|
||||
*******************************************************************************/
|
||||
@@ -77,7 +73,6 @@ public class CrmHealthChecks
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow a host transport adapter.
|
||||
*******************************************************************************/
|
||||
@@ -87,141 +82,151 @@ public class CrmHealthChecks
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the CRM checks with the instance's core health registry.
|
||||
** Production may run repeatedly; an already registered name is kept.
|
||||
** Register fixed core definitions without database reads or captured user sessions.
|
||||
** Legacy indicator names stay unchanged for existing middleware callers.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, CrmQBitConfig config)
|
||||
{
|
||||
new CrmHealthChecks(config).registerWith(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register these checks (with this transport) with the core health registry.
|
||||
*******************************************************************************/
|
||||
public void registerWith(QInstance instance)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
add(registry, EMAIL, "CRM email transport", HealthCategory.EMAIL, 300);
|
||||
add(registry, SEQUENCE_LAG, "CRM sequence lag", HealthCategory.JOBS, 300);
|
||||
add(registry, LEAD_SCORE, "CRM lead scoring", HealthCategory.JOBS, 900);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run all checks once in the caller's context, without registering anything.
|
||||
*******************************************************************************/
|
||||
public Map<String, HealthCheckOutcome> run() throws QException
|
||||
{
|
||||
Map<String, HealthCheckOutcome> outcomes = new LinkedHashMap<>();
|
||||
for(String name : List.of(EMAIL, SEQUENCE_LAG, LEAD_SCORE))
|
||||
CrmHealthChecks checks = new CrmHealthChecks(config);
|
||||
Map<String, String> names = Map.of("crm.email", "crm.email", "crm.sequence-lag", "crm.sequenceLag", "crm.lead-score", "crm.leadScore");
|
||||
names.forEach((name, legacyName) ->
|
||||
{
|
||||
outcomes.put(name, check(name).run(new HealthCheckInput()));
|
||||
}
|
||||
return (outcomes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The core SPI body for one named check. Failures are reported as UNKNOWN
|
||||
** with a fixed message, so no exception text (or value) leaves the check.
|
||||
*******************************************************************************/
|
||||
public HealthCheck check(String name)
|
||||
{
|
||||
return (new HealthCheck()
|
||||
{
|
||||
/*******************************************************************
|
||||
** Evaluate the named check in the current QContext.
|
||||
*******************************************************************/
|
||||
@Override
|
||||
public HealthCheckOutcome run(HealthCheckInput input)
|
||||
if(registry.getChecks().stream().noneMatch(check -> name.equals(check.getName())))
|
||||
{
|
||||
try
|
||||
{
|
||||
return (switch(name)
|
||||
registry.withCheck(new HealthCheckMetaData().withName(name).withLabel(name).withComponent("crm")
|
||||
.withCategory(HealthCategory.INTEGRATION).withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withIntervalSeconds(300).withTimeoutMs(5000).withCheckInstance(input ->
|
||||
{
|
||||
case EMAIL -> email();
|
||||
case SEQUENCE_LAG -> sequenceLag();
|
||||
case LEAD_SCORE -> leadScore();
|
||||
default -> throw (new IllegalArgumentException("Unknown CRM health check"));
|
||||
});
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("CRM check could not be completed"));
|
||||
}
|
||||
if(input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("CRM check deadline exceeded"));
|
||||
}
|
||||
HealthCheckResult result = checks.new Indicator(legacyName, QContext.getQSession()).check(input.getQInstance());
|
||||
return (new HealthCheckOutcome().withStatus(com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(result.getStatus().name()))
|
||||
.withMessage("CRM health check " + result.getStatus().name()));
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add one check definition unless the name is already registered.
|
||||
** Run all checks without registering tables or starting background workers.
|
||||
*******************************************************************************/
|
||||
private void add(HealthChecksMetaData registry, String name, String label, HealthCategory category, int intervalSeconds)
|
||||
public Map<String, HealthCheckResult> run() throws QException
|
||||
{
|
||||
if(registry.getChecks().stream().anyMatch(existing -> name.equals(existing.getName())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
registry.withCheck(new HealthCheckMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withComponent(COMPONENT)
|
||||
.withCategory(category)
|
||||
.withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withSeverity(AlertSeverity.WARNING)
|
||||
.withIntervalSeconds(intervalSeconds)
|
||||
.withTimeoutMs(5000)
|
||||
.withCheckInstance(check(name)));
|
||||
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 HealthCheckOutcome email() throws QException
|
||||
private HealthCheckResult email() throws QException
|
||||
{
|
||||
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED
|
||||
? HealthCheckOutcome.degraded("Email transport not configured")
|
||||
: HealthCheckOutcome.up("Email transport ready"));
|
||||
return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query just the oldest active due enrollment.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome sequenceLag() throws QException
|
||||
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 (rows.isEmpty()
|
||||
? HealthCheckOutcome.up("No sequence step is more than 30 minutes late")
|
||||
: HealthCheckOutcome.degraded("A sequence step is more than 30 minutes late"));
|
||||
return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No successful score pass, or one more than three hours old, needs attention.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome leadScore() throws QException
|
||||
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 (last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
|
||||
? HealthCheckOutcome.degraded("No lead score pass succeeded in the last 3 hours")
|
||||
: HealthCheckOutcome.up("Lead scores are current"));
|
||||
return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
|
||||
? HealthStatus.DEGRADED : HealthStatus.UP));
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -105,11 +105,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// modifyDate is kept at millisecond precision; a microsecond watermark //
|
||||
// would skip a change made in the same millisecond the pass started. //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
Instant startedAt = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
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();
|
||||
|
||||
@@ -33,19 +33,14 @@ import com.kingsrook.qbits.crm.health.CrmHealthChecks;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -56,41 +51,27 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class CrmHealthBulkLoadTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No transport or score run needs attention; the producer registered the
|
||||
** same checks with the core health registry.
|
||||
** 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(CrmHealthChecks.EMAIL).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
for(HealthCheckMetaData registered : crmChecks(QContext.getQInstance()))
|
||||
assertThat(results.get("crm.email").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(results.get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
checks.indicators().forEach(indicator ->
|
||||
{
|
||||
assertThat(registered.getCheckInstance().run(new HealthCheckInput()).getStatus()).isEqualTo(results.get(registered.getName()).getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The producer registers exactly the three CRM checks as valid core
|
||||
** definitions, and re-registration does not duplicate them.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producerRegistersCoreHealthChecks()
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
assertThat(crmChecks(instance)).extracting(HealthCheckMetaData::getName)
|
||||
.containsExactlyInAnyOrder(CrmHealthChecks.EMAIL, CrmHealthChecks.SEQUENCE_LAG, CrmHealthChecks.LEAD_SCORE);
|
||||
assertThat(crmChecks(instance)).allSatisfy(check ->
|
||||
{
|
||||
assertThat(check.getCheckInstance()).isNotNull();
|
||||
assertThat(check.getTimeoutMs()).isLessThan(check.getIntervalSeconds() * 1000);
|
||||
try
|
||||
{
|
||||
assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
});
|
||||
CrmHealthChecks.register(instance, new CrmQBitConfig());
|
||||
assertThat(crmChecks(instance)).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,8 +101,8 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
.withValue("lastSuccess", Instant.now().minus(181, ChronoUnit.MINUTES)));
|
||||
com.kingsrook.qbits.crm.core.CrmRecords.update("crm_sequence_enrollment", new QRecord().withValue("id", 1)
|
||||
.withValue("nextStepDate", Instant.now().minus(31, ChronoUnit.MINUTES)));
|
||||
assertThat(checks.run().get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(checks.run().get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(checks.run().get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(checks.run().get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +120,7 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
throw (new IllegalStateException("unavailable"));
|
||||
}
|
||||
});
|
||||
assertThat(checks.check(CrmHealthChecks.EMAIL).run(new HealthCheckInput()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
@@ -182,36 +163,27 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The core HealthRunner runs each check on a worker thread with no caller
|
||||
** QContext, as a QSystemUserSession. A registered CRM check must succeed that
|
||||
** way on a host whose authentication rejects tokenless sessions (OAuth2, Auth0).
|
||||
** Health middleware invokes checks in worker threads without a caller QContext.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registeredChecksRunAsCoreSystemUserOnTokenRequiredHost() throws Exception
|
||||
void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("token-required")
|
||||
.withType(QAuthenticationType.MOCK)
|
||||
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
|
||||
HealthCheckMetaData sequenceLag = crmChecks(instance).stream()
|
||||
.filter(check -> CrmHealthChecks.SEQUENCE_LAG.equals(check.getName())).findFirst().orElseThrow();
|
||||
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1);
|
||||
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
try
|
||||
{
|
||||
return (sequenceLag.getCheckInstance().run(new HealthCheckInput().withQInstance(instance)).getStatus());
|
||||
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));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}).get();
|
||||
assertThat(status).isEqualTo(HealthStatus.UP);
|
||||
assertThat(QContext.getQInstance()).isSameAs(instance);
|
||||
@@ -220,12 +192,78 @@ class CrmHealthBulkLoadTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CRM definitions in the core health registry.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
private static List<HealthCheckMetaData> crmChecks(QInstance instance)
|
||||
@Test
|
||||
void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception
|
||||
{
|
||||
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
|
||||
.filter(check -> "crm".equals(check.getComponent())).toList());
|
||||
var instance = QContext.getQInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("token-required")
|
||||
.withType(QAuthenticationType.MOCK)
|
||||
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
|
||||
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1);
|
||||
return (indicator.check(instance).getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
}).get();
|
||||
assertThat(status).isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core registration is idempotent, preserves tenant visibility and legacy results.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreRegistrationPreservesCallerAndWorkerContexts() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var session = QContext.getQSession();
|
||||
CrmHealthChecks.register(instance, new CrmQBitConfig());
|
||||
var registry = com.kingsrook.qqq.backend.core.health.HealthChecksMetaData.ofOrWithNew(instance);
|
||||
assertThat(registry.getChecks()).extracting(check -> check.getName())
|
||||
.containsExactlyInAnyOrder("crm.email", "crm.sequence-lag", "crm.lead-score");
|
||||
var check = registry.getChecks().stream().filter(value -> value.getName().equals("crm.sequence-lag")).findFirst().orElseThrow();
|
||||
P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 9).withValue("contactId", 2)
|
||||
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("nextStepDate", Instant.now().minus(2, ChronoUnit.HOURS)));
|
||||
instance.addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("healthTenant"));
|
||||
instance.getTable("crm_sequence_enrollment").withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock()
|
||||
.withFieldName("contactId").withSecurityKeyType("healthTenant"));
|
||||
session.withSecurityKeyValue("healthTenant", 1);
|
||||
var input = new com.kingsrook.qqq.backend.core.health.HealthCheckInput().withQInstance(instance);
|
||||
assertThat(check.getCheckInstance().run(input).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UP);
|
||||
assertThat(QContext.getQSession()).isSameAs(session);
|
||||
session.withSecurityKeyValue("healthTenant", 2);
|
||||
assertThat(check.getCheckInstance().run(input).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.DEGRADED);
|
||||
assertThat(new CrmHealthChecks(new CrmQBitConfig()).indicators(session).get(1).check(instance).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
var worker = java.util.concurrent.CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = check.getCheckInstance().run(input);
|
||||
var legacy = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1).check(instance);
|
||||
assertThat(result.getStatus().name()).isEqualTo(legacy.getStatus().name());
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
assertThat(QContext.getQSession()).isNull();
|
||||
return (result.getStatus());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new AssertionError(e));
|
||||
}
|
||||
}).get();
|
||||
// A system session authenticates the worker but does not invent tenant security keys.
|
||||
assertThat(worker).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UP);
|
||||
assertThat(check.getCheckInstance().run(input.withDeadline(Instant.EPOCH)).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for CrmQBitProducer.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Migration opt-in removes metadata completely without changing legacy hosts.
|
||||
*******************************************************************************/
|
||||
class SharedEmailTemplateMetadataTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Existing hosts keep their actual legacy table, source and configuration.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void legacyDefaultRemains()
|
||||
{
|
||||
assertThat(new CrmQBitConfig().getSharedEmailTemplates()).isFalse();
|
||||
assertThat(QContext.getQInstance().getTable("crm_email_template")).isNotNull();
|
||||
assertThat(QContext.getQInstance().getPossibleValueSource("crm_email_template")).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The opt-in host has one owner and no retired-table or dummy-source metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharedOwnerMetadataValidates() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withTemplatePermissionBaseName("crm.manage")).produce(instance);
|
||||
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables("memory").withSharedEmailTemplates(true))
|
||||
.produce(instance).addSelfToInstance(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(instance.getTable("crm_email_template")).isNull();
|
||||
assertThat(instance.getPossibleValueSource("crm_email_template")).isNull();
|
||||
assertThat(instance.getTable("email_template")).isNotNull();
|
||||
assertThat(instance.getTable("crm_sequence_step").getField("emailTemplateId").getPossibleValueSourceName()).isEqualTo("email_template");
|
||||
assertThat(instance.getTable("crm_email_message").getField("emailTemplateId").getPossibleValueSourceName()).isEqualTo("email_template");
|
||||
assertThat(instance.getProcess("crm_send_email").getInputField("emailTemplateId").orElseThrow().getPossibleValueSourceName()).isEqualTo("email_template");
|
||||
assertThat(instance.getApp("crm").getSections()).allSatisfy(section -> assertThat(section.getTables()).doesNotContain("crm_email_template"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsupported tenant opt-in fails before changing existing metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void optInRejectsMissingOwnerAndTenancy()
|
||||
{
|
||||
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withSharedEmailTemplates(true))
|
||||
.produce(new QInstance())).hasMessageContaining("installed qbit-email");
|
||||
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withSharedEmailTemplates(true).withTenantSecurityKeyType("tenant"))
|
||||
.produce(new QInstance())).hasMessageContaining("non-tenant");
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,32 @@ class CrmActionsTest extends BaseTest
|
||||
assertThat(QContext.getQInstance().getProcess("crm_move_deal_stage").getInputField("targetStageId").orElseThrow().getPossibleValueSourceFilter()).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Runtime Review uses the shared owner even though action field prototypes are legacy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharedTemplateReviewDoesNotReadRetiredTable() throws Exception
|
||||
{
|
||||
seed(CrmActions.action("crm_send_email"));
|
||||
var instance = QContext.getQInstance();
|
||||
new com.kingsrook.qbits.email.EmailQBitProducer().withQBitConfig(new com.kingsrook.qbits.email.EmailQBitConfig()
|
||||
.withBackendName(BACKEND_NAME).withTemplatePermissionBaseName("crm.manage")).produce(instance);
|
||||
new com.kingsrook.qqq.backend.core.instances.QInstanceValidator().validate(instance);
|
||||
var grants = new java.util.HashSet<>(CrmPermissions.ALL);
|
||||
grants.addAll(com.kingsrook.qbits.email.permissions.EmailPermissions.ALL);
|
||||
QContext.getQSession().setPermissions(grants);
|
||||
com.kingsrook.qbits.crm.api.CrmApi.config().withSharedEmailTemplates(true);
|
||||
com.kingsrook.qbits.email.api.EmailApi.ensureTemplate(new com.kingsrook.qbits.email.api.EmailApiTemplate().withName("shared_review").withLabel("Shared")
|
||||
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(com.kingsrook.qbits.email.api.EmailApiCategory.TRANSACTIONAL).withSubject("Hi").withBody("Body"));
|
||||
Integer template = rows("email_template").getFirst().getValueInteger("id");
|
||||
instance.getTables().remove("crm_email_template");
|
||||
instance.getProcess("crm_send_email").getInputField("emailTemplateId").orElseThrow().setPossibleValueSourceName("email_template");
|
||||
var values = inputs();
|
||||
values.put("emailTemplateId", template);
|
||||
var result = review("crm_send_email", values, List.of(1));
|
||||
assertThat(lines(result, false)).isNotEmpty().allMatch(line -> line.getStatus() != Status.ERROR);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative and management actions have distinct permission grants.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -20,13 +20,28 @@
|
||||
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitProducer;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiTemplate;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
@@ -59,6 +74,33 @@ public class CrmEmailTransportTest extends BaseTest
|
||||
RecordingProvider.last = null;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared mode selects the email-owned template and records the final subject.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharedTemplateUsesOwnerAndExactSubject() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig()
|
||||
.withBackendName(BACKEND_NAME).withTemplatePermissionBaseName("crm.manage")).produce(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
Set<String> permissions = new HashSet<>(CrmPermissions.ALL);
|
||||
permissions.addAll(EmailPermissions.ALL);
|
||||
QContext.getQSession().setPermissions(permissions);
|
||||
new InsertAction().execute(new InsertInput("email_provider")
|
||||
.withRecord(new QRecord().withValue("name", "test").withValue("label", "Test").withValue("kind", "SMTP")
|
||||
.withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE").withValue("fromAddress", "sender@example.test").withValue("enabled", true).withValue("isDefault", true)));
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("shared")
|
||||
.withLabel("Shared").withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject("Message {{message.id}}").withBody("<p>Native HTML</p>"));
|
||||
var templates = new QueryAction().execute(new QueryInput("email_template")).getRecords();
|
||||
var transport = new CrmEmailTransport(CrmApi.config().withSharedEmailTemplates(true));
|
||||
var first = transport.sendTemplateMessage("manual", "42", 1, "recipient@example.test", templates.getFirst().getValueInteger("id"), null, null, false, Map.of());
|
||||
assertThat(first.subject()).isEqualTo("Message " + first.delivery().messageId());
|
||||
var repeated = transport.sendTemplateMessage("manual", "42", 1, "recipient@example.test", templates.getFirst().getValueInteger("id"), null, null, false, Map.of());
|
||||
assertThat(repeated).isEqualTo(first);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public constructor allows the core dispatcher to instantiate this provider.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user