feat: PII, tenancy, search/API, help bundle, health, full GDPR erase, bulk-load profiles #13
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)
|
||||
|
||||
|
||||
+21
-20
@@ -5,7 +5,8 @@
|
||||
-- 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),
|
||||
@@ -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,
|
||||
@@ -460,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,
|
||||
|
||||
@@ -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,12 @@ 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;
|
||||
|
||||
@@ -56,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;
|
||||
|
||||
|
||||
|
||||
@@ -137,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.
|
||||
*******************************************************************************/
|
||||
@@ -308,4 +268,99 @@ public class CrmQBitConfig implements QBitConfig
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -41,8 +41,11 @@ 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;
|
||||
@@ -113,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()));
|
||||
@@ -160,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)
|
||||
@@ -359,6 +369,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
|
||||
registerHooks(qInstance);
|
||||
CrmHelpBundleStandIn.apply(output);
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -6,7 +6,8 @@
|
||||
-- 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),
|
||||
@@ -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,
|
||||
@@ -483,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,
|
||||
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user