Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f994e127b
|
||
|
|
212c7aaa77
|
||
|
|
40a93c3286
|
||
|
|
da0ba96966
|
||
|
|
b7b146259f
|
||
|
|
7ee43485c3
|
||
|
|
019d93a5d7
|
||
|
|
99102d3175
|
||
|
|
8d9dfb9172
|
||
|
|
e1fb2c0deb
|
||
|
|
68220ec915
|
||
|
|
666cec834e
|
No files matched your search
@@ -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 |
|
||||
|---|---|
|
||||
@@ -72,7 +79,7 @@ Permissions are independent grants. Every CRM-produced table, process and widget
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
+37
-24
@@ -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),
|
||||
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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -28,6 +28,7 @@ package com.kingsrook.qbits.crm;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer;
|
||||
@@ -39,7 +40,12 @@ import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomi
|
||||
import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
import com.kingsrook.qbits.crm.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,6 +60,9 @@ 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.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
@@ -61,6 +70,7 @@ 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;
|
||||
@@ -106,6 +116,8 @@ 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()));
|
||||
@@ -139,6 +151,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 +165,11 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
|
||||
{
|
||||
String tableName = table.getName();
|
||||
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 +194,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));
|
||||
@@ -324,7 +362,14 @@ 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);
|
||||
CrmHelpBundleStandIn.apply(output);
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ 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;
|
||||
@@ -42,6 +43,8 @@ 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;
|
||||
@@ -84,7 +87,7 @@ public final class CrmAction
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CrmAction.class);
|
||||
|
||||
public static final String EMAIL_WARNING = "Email sending arrives with the email transport";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility holder.
|
||||
@@ -162,6 +165,14 @@ public final class CrmAction
|
||||
*******************************************************************************/
|
||||
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;
|
||||
@@ -190,7 +201,7 @@ public final class CrmAction
|
||||
try
|
||||
{
|
||||
QRecord row = requireRow(action, input, id);
|
||||
String reason = check(action, input, row);
|
||||
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
|
||||
if(reason != null)
|
||||
{
|
||||
add(lines, Status.WARNING, reason, reason, id);
|
||||
@@ -253,6 +264,14 @@ public final class CrmAction
|
||||
*******************************************************************************/
|
||||
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;
|
||||
|
||||
@@ -267,14 +286,14 @@ public final class CrmAction
|
||||
try
|
||||
{
|
||||
QRecord row = requireRow(action, input, id);
|
||||
String reason = check(action, input, row);
|
||||
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);
|
||||
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);
|
||||
@@ -345,7 +364,7 @@ public final class CrmAction
|
||||
/*******************************************************************************
|
||||
** Shared read-only checks for Review and Result.
|
||||
*******************************************************************************/
|
||||
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
|
||||
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
for(QFieldMetaData field : CrmActions.inputFields(action))
|
||||
{
|
||||
@@ -365,15 +384,7 @@ public final class CrmAction
|
||||
switch(action.operation())
|
||||
{
|
||||
case "email":
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qbits.crm.email.CrmEmailTransport");
|
||||
return ("Email transport is present; sending integration is not configured for this action yet");
|
||||
}
|
||||
catch(ClassNotFoundException expected)
|
||||
{
|
||||
return (EMAIL_WARNING);
|
||||
}
|
||||
return (CrmEmails.skipReason(row, transport));
|
||||
case "subscribe", "unsubscribe":
|
||||
if(!StringUtils.hasContent(row.getValueString("email")))
|
||||
{
|
||||
@@ -479,7 +490,7 @@ public final class CrmAction
|
||||
/*******************************************************************************
|
||||
** Invoke the existing business operations only at Load time.
|
||||
*******************************************************************************/
|
||||
private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
|
||||
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))
|
||||
@@ -494,6 +505,16 @@ public final class CrmAction
|
||||
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" ->
|
||||
{
|
||||
|
||||
@@ -148,6 +148,12 @@ public class CrmActions implements MetaDataProducerInterface<MetaDataProducerMul
|
||||
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"));
|
||||
|
||||
@@ -288,7 +288,8 @@ 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");
|
||||
@@ -323,7 +324,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"));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -126,30 +122,7 @@ public class Contact 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);
|
||||
}
|
||||
@@ -157,19 +130,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 +154,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)
|
||||
@@ -214,10 +187,10 @@ public class Contact extends QRecordEntity
|
||||
@QField(isRequired = true, 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 +214,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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-121
@@ -18,147 +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 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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend operation used by the streamed CRM actions for GDPR anonymization of a contact.
|
||||
** Backend operation for the streamed Erase Personal Data action.
|
||||
*******************************************************************************/
|
||||
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]";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+59
-48
@@ -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;
|
||||
@@ -79,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()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -92,7 +109,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
.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")
|
||||
@@ -111,6 +127,18 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
///////////////////////////////////////////////////////////
|
||||
// query enrollments where status=ACTIVE and due now //
|
||||
///////////////////////////////////////////////////////////
|
||||
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)
|
||||
{
|
||||
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();
|
||||
@@ -130,7 +158,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
|
||||
try
|
||||
{
|
||||
processEnrollment(enrollment);
|
||||
processEnrollment(enrollment, transport);
|
||||
processedCount++;
|
||||
}
|
||||
catch(Exception e)
|
||||
@@ -154,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;
|
||||
|
||||
@@ -167,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 //
|
||||
/////////////////////////////////////////////
|
||||
@@ -237,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 ->
|
||||
@@ -257,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.
|
||||
***************************************************************************/
|
||||
@@ -524,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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)
|
||||
** CRM checks for hosts to register with their health middleware. Run in the
|
||||
** caller's tenant context; no check elevates access or returns personal values.
|
||||
*******************************************************************************/
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public List<HealthIndicator> indicators()
|
||||
{
|
||||
return (indicators(QContext.getQSession() == null ? new QSession() : 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.
|
||||
*******************************************************************************/
|
||||
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);
|
||||
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,135 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.help;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)
|
||||
** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks
|
||||
** process/PVS help slots: action content is applied to frontend steps instead;
|
||||
** enum entries remain in the bundle for the central loader.
|
||||
*******************************************************************************/
|
||||
public final class CrmHelpBundleStandIn
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmHelpBundleStandIn()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the resource eagerly so malformed shipped help fails production.
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml"))
|
||||
{
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1
|
||||
|| !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("Invalid CRM bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120
|
||||
|| entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("Invalid or duplicate help entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("Could not load help/qbit-crm.yaml", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply help to the objects produced by this QBit only.
|
||||
*******************************************************************************/
|
||||
public static void apply(MetaDataProducerMultiOutput output) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : output.getEach(QTableMetaData.class))
|
||||
{
|
||||
table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help"));
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
String key = "table:" + table.getName() + ";field:" + field.getName();
|
||||
if(entries.containsKey(key))
|
||||
{
|
||||
field.withHelpContent(content(entries, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QProcessMetaData process : output.getEach(QProcessMetaData.class))
|
||||
{
|
||||
QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help");
|
||||
for(var step : process.getStepList())
|
||||
{
|
||||
if(step instanceof QFrontendStepMetaData frontend)
|
||||
{
|
||||
frontend.withHelpContent(help.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QAppMetaData app : output.getEach(QAppMetaData.class))
|
||||
{
|
||||
app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 has no summary property, so retain the summary in the bundle only.
|
||||
*******************************************************************************/
|
||||
private static QHelpContent content(Map<String, JsonNode> entries, String key) throws QException
|
||||
{
|
||||
if(!entries.containsKey(key))
|
||||
{
|
||||
throw (new QException("Missing CRM help entry: " + key));
|
||||
}
|
||||
return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText()));
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
+115
-50
@@ -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;
|
||||
@@ -83,7 +90,6 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
.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")
|
||||
@@ -99,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 //
|
||||
///////////////////////////////////////////////
|
||||
@@ -131,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));
|
||||
|
||||
@@ -139,15 +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())
|
||||
{
|
||||
totalContacts++;
|
||||
if(recalculate(contactRecord, rules))
|
||||
QRecord change = calculateChange(contactRecord, rules, companies);
|
||||
if(change != null)
|
||||
{
|
||||
updatedCount++;
|
||||
changes.add(change);
|
||||
}
|
||||
lastId = contactRecord.getValueInteger("id");
|
||||
}
|
||||
writeScores(changes);
|
||||
updatedCount += changes.size();
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// if page was not full, we are done //
|
||||
@@ -158,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());
|
||||
@@ -173,18 +211,19 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
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();
|
||||
recalculate(contact, rules);
|
||||
QRecord change = calculateChange(contact, rules, loadCompanies(List.of(contact)));
|
||||
writeScores(change == null ? List.of() : List.of(change));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Write only changed scores; return whether a write was needed.
|
||||
** Compute a score update only when the result changed.
|
||||
*******************************************************************************/
|
||||
private boolean recalculate(QRecord contact, List<LeadScoreRule> rules) throws QException
|
||||
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))
|
||||
if(evaluateRule(rule, contact, companies))
|
||||
{
|
||||
score += rule.getScoreAdjustment();
|
||||
}
|
||||
@@ -192,11 +231,27 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
Integer current = contact.getValueInteger("leadScore");
|
||||
if(Objects.equals(current == null ? 0 : current, score))
|
||||
{
|
||||
return (false);
|
||||
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"));
|
||||
}
|
||||
new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score)));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +259,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
** 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)
|
||||
@@ -213,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)
|
||||
@@ -233,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));
|
||||
|
||||
@@ -272,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");
|
||||
@@ -58,7 +58,7 @@ public final class CrmPermissions
|
||||
"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_assign_company_owner",
|
||||
"crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence");
|
||||
"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 + "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,8 +489,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));
|
||||
}
|
||||
}
|
||||
@@ -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,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,11 @@ 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
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,189 @@
|
||||
/*
|
||||
* 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.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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,152 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
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.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help covers app every table main field process and enum.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception
|
||||
{
|
||||
var entries = CrmHelpBundleStandIn.entries();
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(entries).containsKey("app:crm;slot:help");
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
assertThat(entries).containsKey("table:" + table.getName() + ";slot:help");
|
||||
assertThat(table.getHelpContent().get("help")).isNotEmpty();
|
||||
if(CrmAppMetaData.MAIN_TABLES.contains(table.getName()))
|
||||
{
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName());
|
||||
assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var process : instance.getProcesses().values())
|
||||
{
|
||||
if(process.getName().startsWith("crm_"))
|
||||
{
|
||||
assertThat(entries).containsKey("process:" + process.getName() + ";slot:help");
|
||||
// Core creates dotted bulk-process names after QBit production; they retain core screen help.
|
||||
if(!process.getName().contains("."))
|
||||
{
|
||||
process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast)
|
||||
.forEach(step -> assertThat(step.getHelpContents()).isNotEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var pvs : instance.getPossibleValueSources().values())
|
||||
{
|
||||
if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm"))
|
||||
{
|
||||
assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help");
|
||||
}
|
||||
}
|
||||
entries.values().forEach(entry ->
|
||||
{
|
||||
assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank();
|
||||
assertThat(entry.get("content").asText()).isNotBlank();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<>();
|
||||
@@ -278,7 +278,7 @@ class CrmRdbmsTest
|
||||
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(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(34);
|
||||
assertThat(stripComments(changelog)).isEqualTo(stripComments(ddl));
|
||||
}
|
||||
|
||||
@@ -370,4 +370,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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class CrmActionsTest extends BaseTest
|
||||
void metadataIsComplete()
|
||||
{
|
||||
assertThat(CrmActions.ACTIONS).hasSize(28);
|
||||
assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(30);
|
||||
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());
|
||||
@@ -154,7 +154,7 @@ class CrmActionsTest extends BaseTest
|
||||
assertThat(process.getMaxInputRecords()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores"))
|
||||
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();
|
||||
}
|
||||
|
||||
+9
-9
@@ -60,10 +60,10 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Email step should create an activity record.
|
||||
** Email without recorded consent pauses without creating a fake activity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEmailStepCreatesActivity() throws QException
|
||||
void testEmailStepRequiresConsent() throws QException
|
||||
{
|
||||
insertActivityType("Email");
|
||||
insertActivityType("Task");
|
||||
@@ -91,8 +91,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
QueryOutput activityQuery = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME));
|
||||
|
||||
assertThat(activityQuery.getRecords()).isNotEmpty();
|
||||
assertThat(activityQuery.getRecords().get(0).getValueString("subject")).contains("Sequence email");
|
||||
assertThat(activityQuery.getRecords()).isEmpty();
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// verify enrollment was advanced //
|
||||
@@ -101,7 +100,8 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
new QueryInput(SequenceEnrollment.TABLE_NAME));
|
||||
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentQuery.getRecords().get(0));
|
||||
assertEquals(1, enrollment.getCurrentStepNumber());
|
||||
assertEquals(0, enrollment.getCurrentStepNumber());
|
||||
assertEquals(CrmEnrollmentStatus.PAUSED.getId(), enrollment.getStatus());
|
||||
}
|
||||
|
||||
|
||||
@@ -142,10 +142,10 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After 3 failures, enrollment should be marked as FAILED.
|
||||
** Opt-out pauses an enrollment without counting it as a delivery failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailureCounting() throws QException
|
||||
void testOptOutPausesWithoutCountingFailure() throws QException
|
||||
{
|
||||
Integer sequenceId = insertSequence("Fail Seq", true, 2, false);
|
||||
Integer contactId = insertContact("Carol", "Fail", true); // doNotEmail = true
|
||||
@@ -181,8 +181,8 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
new QueryInput(SequenceEnrollment.TABLE_NAME));
|
||||
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentQuery.getRecords().get(0));
|
||||
assertEquals(CrmEnrollmentStatus.FAILED.getId(), enrollment.getStatus());
|
||||
assertEquals(3, enrollment.getFailureCount());
|
||||
assertEquals(CrmEnrollmentStatus.PAUSED.getId(), enrollment.getStatus());
|
||||
assertEquals(2, enrollment.getFailureCount());
|
||||
assertThat(enrollment.getLastFailureMessage()).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user