Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 2babcaf3b4 fix(ui): plain table and field labels, contact layout, 4-column child lists, nullable names and owners, close dates on closed-stage inserts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:39:27 -05:00
james.maesandClaude Opus 5.5 31964ff0e0 fix(home): grouped currency amounts, Stage/Deals/Weighted columns, compact Today statistics, readable timeline dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:39:26 -05:00
james.maes 50acf63211 Merge pull request #13 from QRun-IO/feature/pii-tenancy-api
feat: PII, tenancy, search/API, help bundle, health, full GDPR erase, bulk-load profiles
2026-10-14 15:00:14 -05:00
james.maesandClaude Opus 5.5 1f994e127b fix(schema): 320-char emails, 500-char UTM fields; cap lead source names from UTM at 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:36 -05:00
james.maesandClaude Opus 5.5 212c7aaa77 fix(home): let Next size home table columns (no fixed widths)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:35 -05:00
james.maesandClaude Opus 5.5 40a93c3286 fix(app): produce a plain QAppMetaData so hosts can attach it as a child app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:35 -05:00
james.maesandClaude Opus 5.5 da0ba96966 feat: PII classification, optional tenancy locks, search fields and API publication
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:55 -05:00
james.maesandClaude Opus 5.5 b7b146259f feat: contingent primary contact and server-side stage and outcome validation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:54 -05:00
james.maesandClaude Opus 5.5 7ee43485c3 feat(privacy): Erase Personal Data covers every CRM table and core audit copies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:54 -05:00
james.maesandClaude Opus 5.5 019d93a5d7 feat: CRM health checks (stand-in qqq#1199) and bulk-load starter profiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:53 -05:00
james.maesandClaude Opus 5.5 99102d3175 feat(help): qbit-crm help bundle with stand-in loader (qqq#1198)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:52 -05:00
james.maes 90cdaaa88d Merge pull request #12 from QRun-IO/feature/platform-reuse
feat: real email and sequences, delivery sync, schedules, notifications, collaboration features
2026-10-14 13:34:57 -05:00
james.maes 424f6ea379 Merge remote-tracking branch 'origin/develop' into feature/platform-reuse 2026-10-14 13:32:17 -05:00
james.maesandClaude Opus 5.5 8d9dfb9172 feat: schedules, collaboration record features and alert types
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:24 -05:00
james.maesandClaude Opus 5.5 e1fb2c0deb feat(deals): won and rotting notifications through QAlerts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:24 -05:00
james.maesandClaude Opus 5.5 68220ec915 feat(scoring): date-decay operators, incremental recalculation and auto-assign on insert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:23 -05:00
james.maesandClaude Opus 5.5 666cec834e feat(email): Send Email and sequence steps send through CrmEmailTransport; delivery sync and consent expiry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:31:22 -05:00
james.maes 4b1d272ac5 Merge pull request #11 from QRun-IO/feature/record-actions
feat(actions): CRM record actions on the standard streamed ETL
2026-10-14 13:06:13 -05:00
67 changed files with 6451 additions and 559 deletions

No files matched your search

+9 -2
View File
@@ -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)
+39 -26
View File
@@ -1,11 +1,12 @@
-- Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0.
-- https://www.apache.org/licenses/LICENSE-2.0
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (33 tables).
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (34 tables).
-- QQQ table names equal SQL names; Java field names map to snake_case columns.
-- Unique constraints match QQQ metadata. Relationship columns are indexed;
-- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns
-- validation and deletion policy, including polymorphic and optional references).
-- Optional host-configured securityFields require matching host-managed columns.
-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy.
-- With tenancy off, do not include it; this base schema has no tenant_key columns.
CREATE TABLE IF NOT EXISTS crm_activity (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -161,27 +162,27 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_record (contact_id);
CREATE TABLE IF NOT EXISTS crm_contact (
first_touch_utm_source VARCHAR(250),
first_touch_utm_medium VARCHAR(250),
first_touch_utm_campaign VARCHAR(250),
first_touch_utm_content VARCHAR(250),
first_touch_utm_term VARCHAR(250),
first_touch_utm_source VARCHAR(500),
first_touch_utm_medium VARCHAR(500),
first_touch_utm_campaign VARCHAR(500),
first_touch_utm_content VARCHAR(500),
first_touch_utm_term VARCHAR(500),
first_touch_landing_page VARCHAR(1000),
first_touch_referrer VARCHAR(1000),
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
last_touch_utm_source VARCHAR(250),
last_touch_utm_medium VARCHAR(250),
last_touch_utm_campaign VARCHAR(250),
last_touch_utm_content VARCHAR(250),
last_touch_utm_term VARCHAR(250),
last_touch_utm_source VARCHAR(500),
last_touch_utm_medium VARCHAR(500),
last_touch_utm_campaign VARCHAR(500),
last_touch_utm_content VARCHAR(500),
last_touch_utm_term VARCHAR(500),
last_touch_landing_page VARCHAR(1000),
last_touch_referrer VARCHAR(1000),
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(250),
secondary_email VARCHAR(250),
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(320),
secondary_email VARCHAR(320),
phone VARCHAR(50),
mobile_phone VARCHAR(50),
job_title VARCHAR(150),
@@ -189,7 +190,7 @@ CREATE TABLE IF NOT EXISTS crm_contact (
company_id INTEGER,
lifecycle_stage_id INTEGER,
lead_source_id INTEGER,
owner_user_id TEXT NOT NULL,
owner_user_id TEXT,
address_line1 VARCHAR(250),
address_line2 VARCHAR(250),
city VARCHAR(100),
@@ -252,7 +253,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
weighted_amount DECIMAL(20,6),
company_id INTEGER,
primary_contact_id INTEGER,
owner_user_id TEXT NOT NULL,
owner_user_id TEXT,
lead_source_id INTEGER,
expected_close_date DATE,
actual_close_date DATE,
@@ -346,7 +347,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email_list_id INTEGER NOT NULL,
contact_id INTEGER,
email VARCHAR(250) NOT NULL,
email VARCHAR(320) NOT NULL,
first_name VARCHAR(100),
status INTEGER DEFAULT 1 NOT NULL,
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
@@ -364,9 +365,13 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
CREATE TABLE IF NOT EXISTS crm_email_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
idempotency_key VARCHAR(250) UNIQUE,
email_message_id VARCHAR(100),
delivered_date TIMESTAMP WITHOUT TIME ZONE,
bounced_date TIMESTAMP WITHOUT TIME ZONE,
activity_id INTEGER,
contact_id INTEGER,
from_address VARCHAR(250) NOT NULL,
from_address VARCHAR(250),
from_name VARCHAR(100),
to_addresses TEXT NOT NULL,
cc_addresses TEXT,
@@ -392,6 +397,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
modify_date TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_message_id ON crm_email_message (email_message_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
@@ -455,18 +461,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
client_hash VARCHAR(64),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
form_type INTEGER NOT NULL,
email VARCHAR(250) NOT NULL,
email VARCHAR(320) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
company VARCHAR(250),
phone VARCHAR(50),
message TEXT,
source_url VARCHAR(1000),
utm_source VARCHAR(250),
utm_medium VARCHAR(250),
utm_campaign VARCHAR(250),
utm_term VARCHAR(250),
utm_content VARCHAR(250),
utm_source VARCHAR(500),
utm_medium VARCHAR(500),
utm_campaign VARCHAR(500),
utm_term VARCHAR(500),
utm_content VARCHAR(500),
referrer VARCHAR(1000),
contact_id INTEGER,
conversion_status INTEGER DEFAULT 1 NOT NULL,
@@ -640,3 +646,10 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
);
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
CREATE TABLE IF NOT EXISTS crm_job_state (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(250) NOT NULL UNIQUE,
last_success TIMESTAMP WITHOUT TIME ZONE,
last_full_pass TIMESTAMP WITHOUT TIME ZONE
);
+11
View File
@@ -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));
}
}
}
}
@@ -0,0 +1,131 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** CRM presentation shared by package-discovered tables and child widgets.
*******************************************************************************/
public final class CrmPresentation
{
private static final Map<String, String> LABELS = Map.ofEntries(
Map.entry("crm_contact", "Contacts"),
Map.entry("crm_company", "Companies"),
Map.entry("crm_deal", "Deals"),
Map.entry("crm_activity", "Activities"),
Map.entry("crm_form_submission", "Web Leads"),
Map.entry("crm_email_list", "Lists"),
Map.entry("crm_pipeline", "Pipelines"),
Map.entry("crm_pipeline_stage", "Pipeline Stages"),
Map.entry("crm_product", "Products"),
Map.entry("crm_email_template", "Email Templates"),
Map.entry("crm_email_sequence", "Email Sequences"),
Map.entry("crm_sequence_step", "Sequence Steps"),
Map.entry("crm_sequence_enrollment", "Sequence Enrollments"),
Map.entry("crm_lead_score_rule", "Lead Score Rules"),
Map.entry("crm_assignment_rule", "Assignment Rules"),
Map.entry("crm_assignment_rule_member", "Assignment Rule Members"),
Map.entry("crm_sales_goal", "Sales Goals"),
Map.entry("crm_currency", "Currencies"),
Map.entry("crm_lifecycle_stage", "Lifecycle Stages"),
Map.entry("crm_lead_source", "Lead Sources"),
Map.entry("crm_industry", "Industries"),
Map.entry("crm_activity_type", "Activity Types"),
Map.entry("crm_activity_outcome", "Activity Outcomes"),
Map.entry("crm_contact_role", "Contact Roles"),
Map.entry("crm_win_loss_reason", "Win/Loss Reasons"),
Map.entry("crm_consent_record", "Consent Records"),
Map.entry("crm_email_list_member", "List Members"),
Map.entry("crm_email_message", "Email Messages"),
Map.entry("crm_deal_contact", "Deal Contacts"),
Map.entry("crm_deal_product", "Deal Products"),
Map.entry("crm_deal_stage_history", "Deal Stage History"),
Map.entry("crm_activity_participant", "Activity Participants"),
Map.entry("crm_external_mapping", "External Mappings"),
Map.entry("crm_job_state", "Job State"));
private static final Map<String, List<String>> CHILD_FIELDS = Map.ofEntries(
Map.entry("crm_contact", List.of("firstName", "lastName", "email", "phone")),
Map.entry("crm_deal", List.of("name", "pipelineStageId", "amount", "expectedCloseDate")),
Map.entry("crm_activity", List.of("subject", "activityTypeId", "startDate")),
Map.entry("crm_form_submission", List.of("formKey", "createDate", "conversionStatus")),
Map.entry("crm_email_list_member", List.of("emailListId", "status")),
Map.entry("crm_consent_record", List.of("consentType", "status", "consentDate")),
Map.entry("crm_sequence_enrollment", List.of("sequenceId", "status", "enrolledDate")),
Map.entry("crm_email_message", List.of("subject", "status", "sentDate")),
Map.entry("crm_deal_contact", List.of("contactId", "contactRoleId", "isPrimary")),
Map.entry("crm_deal_product", List.of("productId", "quantity", "unitPrice", "totalAmount")),
Map.entry("crm_deal_stage_history", List.of("fromStageId", "toStageId", "transitionDate", "userId")));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmPresentation()
{
}
/*******************************************************************************
** Explicit labels avoid exposing technical table and user-id names.
*******************************************************************************/
public static void configureTable(QTableMetaData table)
{
table.setLabel(LABELS.get(table.getName()));
Map<String, String> users = Map.of("ownerUserId", "Owner", "assignedToUserId", "Assigned To",
"assignToUserId", "Assign To", "enrolledByUserId", "Enrolled By", "convertedByUserId", "Converted By", "userId", "User");
table.getFields().values().forEach(field ->
{
if(users.containsKey(field.getName()))
{
field.setLabel(users.get(field.getName()));
}
});
Map<String, List<String>> money = Map.of("crm_deal", List.of("amount", "amountInBaseCurrency", "weightedAmount"),
"crm_deal_product", List.of("unitPrice", "totalAmount"), "crm_product", List.of("unitPrice"), "crm_sales_goal", List.of("targetAmount"));
for(String field : money.getOrDefault(table.getName(), List.of()))
{
table.getField(field).setDisplayFormat(DisplayFormat.CURRENCY);
}
}
/*******************************************************************************
** RC.6 supports a per-widget field list, preserving each full child table.
*******************************************************************************/
public static void configureChildList(QWidgetMetaData widget, String childTable)
{
List<String> fields = CHILD_FIELDS.get(childTable);
if(fields != null)
{
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(fields));
}
}
}
@@ -27,10 +27,8 @@ package com.kingsrook.qbits.crm;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -43,6 +41,15 @@ public class CrmQBitConfig implements QBitConfig
{
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
private String tenantSecurityKeyType;
private String tenantAllAccessKeyName;
private String apiName;
private String apiVersion;
private String esbDestinationName;
private String schedulerName;
private Boolean schedulesEnabled = true;
private String messagingProviderName;
private String publicBaseUrl;
@@ -53,8 +60,6 @@ public class CrmQBitConfig implements QBitConfig
private Boolean inferBackendNames = true;
private Boolean appEnabled = true;
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private List<QFieldMetaData> securityFields;
private List<RecordSecurityLock> recordSecurityLocks;
@@ -134,48 +139,6 @@ public class CrmQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for securityFields
*******************************************************************************/
public List<QFieldMetaData> getSecurityFields()
{
return (this.securityFields);
}
/*******************************************************************************
** Fluent setter for securityFields
*******************************************************************************/
public CrmQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
{
this.securityFields = securityFields;
return (this);
}
/*******************************************************************************
** Getter for recordSecurityLocks
*******************************************************************************/
public List<RecordSecurityLock> getRecordSecurityLocks()
{
return (this.recordSecurityLocks);
}
/*******************************************************************************
** Fluent setter for recordSecurityLocks
*******************************************************************************/
public CrmQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
{
this.recordSecurityLocks = recordSecurityLocks;
return (this);
}
/*******************************************************************************
** Whether to infer snake_case database column names.
*******************************************************************************/
@@ -271,4 +234,133 @@ public class CrmQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Scheduler configuration: schedulerName.
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Configure schedulerName.
*******************************************************************************/
public CrmQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Scheduler configuration: schedulesEnabled.
*******************************************************************************/
public Boolean getSchedulesEnabled()
{
return (schedulesEnabled);
}
/*******************************************************************************
** Configure schedulesEnabled.
*******************************************************************************/
public CrmQBitConfig withSchedulesEnabled(Boolean schedulesEnabled)
{
this.schedulesEnabled = schedulesEnabled;
return (this);
}
/*******************************************************************************
** Getter for tenantSecurityKeyType.
*******************************************************************************/
public String getTenantSecurityKeyType()
{
return (tenantSecurityKeyType);
}
/*******************************************************************************
** Configure tenantSecurityKeyType; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withTenantSecurityKeyType(String tenantSecurityKeyType)
{
this.tenantSecurityKeyType = tenantSecurityKeyType;
return (this);
}
/*******************************************************************************
** Getter for tenantAllAccessKeyName.
*******************************************************************************/
public String getTenantAllAccessKeyName()
{
return (tenantAllAccessKeyName);
}
/*******************************************************************************
** Configure tenantAllAccessKeyName; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withTenantAllAccessKeyName(String tenantAllAccessKeyName)
{
this.tenantAllAccessKeyName = tenantAllAccessKeyName;
return (this);
}
/*******************************************************************************
** Getter for apiName.
*******************************************************************************/
public String getApiName()
{
return (apiName);
}
/*******************************************************************************
** Configure apiName; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withApiName(String apiName)
{
this.apiName = apiName;
return (this);
}
/*******************************************************************************
** Getter for apiVersion.
*******************************************************************************/
public String getApiVersion()
{
return (apiVersion);
}
/*******************************************************************************
** Configure apiVersion; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withApiVersion(String apiVersion)
{
this.apiVersion = apiVersion;
return (this);
}
/*******************************************************************************
** Getter for esbDestinationName.
*******************************************************************************/
public String getEsbDestinationName()
{
return (esbDestinationName);
}
/*******************************************************************************
** Configure esbDestinationName; null disables the optional integration.
*******************************************************************************/
public CrmQBitConfig withEsbDestinationName(String esbDestinationName)
{
this.esbDestinationName = esbDestinationName;
return (this);
}
}
@@ -28,6 +28,7 @@ package com.kingsrook.qbits.crm;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer;
@@ -39,7 +40,12 @@ import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomi
import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealProduct;
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qbits.crm.security.CrmRelationshipValidator;
import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData;
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
@@ -54,6 +60,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
@@ -61,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
@@ -106,18 +116,21 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
join.setName(snakeCase(join.getName()));
}
CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig);
CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig);
for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
{
widget.setName(snakeCase(widget.getName()));
QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker();
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker));
widget.setGridColumns(12);
widget.setGridColumns(com.kingsrook.qbits.crm.widgets.CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
{
String normalizedJoinName = snakeCase(joinName);
widget.withDefaultValue("joinName", normalizedJoinName);
QJoinMetaData join = metaDataProducerMultiOutput.getEach(QJoinMetaData.class).stream()
.filter(candidate -> candidate.getName().equals(normalizedJoinName)).findFirst().orElseThrow();
CrmPresentation.configureChildList(widget, join.getRightTable());
QTableMetaData parent = metaDataProducerMultiOutput.get(QTableMetaData.class, join.getLeftTable());
boolean alreadyAttached = parent.getSections() != null && parent.getSections().stream()
.anyMatch(section -> section.getWidgetName() != null && snakeCase(section.getWidgetName()).equals(widget.getName()));
@@ -129,8 +142,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
}
}
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_form_submission").setLabel("Web Leads");
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_email_list").setLabel("Lists");
metaDataProducerMultiOutput.get(QTableMetaData.class, Contact.TABLE_NAME).withSection(
new QFieldSection("crm_contact_timeline", "Timeline", new QIcon("history"), Tier.T2, List.of())
.withWidgetName("crm_contact_timeline"));
@@ -139,6 +150,12 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
process.setPermissionRules(CrmPermissions.processRules(process.getName()));
process.setSourceQBitName(getQBitMetaData().getName());
Integer seconds = Map.of("crm_process_sequence_steps", 300, "crm_calculate_lead_scores", 3600,
"crm_sync_email_delivery", 900, "crm_expire_consents", 86400).get(process.getName());
if(seconds != null && qBitConfig.getSchedulerName() != null && !Boolean.FALSE.equals(qBitConfig.getSchedulesEnabled()))
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(qBitConfig.getSchedulerName()).withRepeatSeconds(seconds));
}
}
/////////////////////////////////////////////////////////////////////////
@@ -147,6 +164,16 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
{
String tableName = table.getName();
CrmPresentation.configureTable(table);
if(tableName.equals(Contact.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class));
}
if(tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CrmRelationshipValidator.class));
addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CrmRelationshipValidator.class));
}
table.setPermissionRules(CrmPermissions.tableRules(tableName));
table.setSourceQBitName(getQBitMetaData().getName());
if(table.getSections() != null)
@@ -171,11 +198,26 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|| tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
{
table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
RecordFeaturesMetaData features = RecordFeaturesMetaData.ofOrWithNew(table)
.withOwnership(new RecordOwnership().withOwnerFieldName("ownerUserId"));
for(RecordFeature feature : List.of(RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.ATTACHMENTS,
RecordFeature.FOLLOW, RecordFeature.TIMELINE, RecordFeature.RECENTS, RecordFeature.FAVORITES))
{
features.withEnabled(feature, true);
}
if(!tableName.equals(Activity.TABLE_NAME))
{
features.withEnabled(RecordFeature.TASKS, true).withEnabled(RecordFeature.ASSIGNMENT, true);
}
}
///////////////////////////////////////////////////////////////
// wire activity timestamp propagation //
///////////////////////////////////////////////////////////////
if(tableName.equals(Contact.TABLE_NAME) || tableName.equals(Deal.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(AutoAssignCustomizer.class));
}
if(tableName.equals(Activity.TABLE_NAME))
{
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(ActivityLastDateCustomizer.class));
@@ -324,7 +366,14 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
.withSourceQBitName(getQBitMetaData().getName()));
output.add(new QAlertTypeMetaData().withName("crm.email.unavailable").withLabel("Email unavailable")
.withSourceQBitName(getQBitMetaData().getName()));
for(String notification : List.of("crm.lead.assigned", "crm.deal.won", "crm.deal.rotting", "crm.task.due"))
{
output.add(new QAlertTypeMetaData().withName(notification).withLabel(notification)
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
}
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
registerHooks(qInstance);
CrmHelpBundleStandIn.apply(output);
return (output);
}
@@ -32,6 +32,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess;
import com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess;
@@ -42,6 +43,8 @@ import com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess;
import com.kingsrook.qbits.crm.deals.processes.CloneDealProcess;
import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess;
import com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.email.CrmEmails;
import com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess;
import com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess;
import com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess;
@@ -84,7 +87,7 @@ public final class CrmAction
{
private static final QLogger LOG = QLogger.getLogger(CrmAction.class);
public static final String EMAIL_WARNING = "Email sending arrives with the email transport";
/*******************************************************************************
** Utility holder.
@@ -162,6 +165,14 @@ public final class CrmAction
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
/*******************************************************************************
** Resolve the host transport; tests may supply a fake.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
private final Map<String, List<Serializable>> duplicateGroups = new LinkedHashMap<>();
private List<QRecord> pair;
@@ -190,7 +201,7 @@ public final class CrmAction
try
{
QRecord row = requireRow(action, input, id);
String reason = check(action, input, row);
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
if(reason != null)
{
add(lines, Status.WARNING, reason, reason, id);
@@ -253,6 +264,14 @@ public final class CrmAction
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
/*******************************************************************************
** Resolve the host transport; tests may supply a fake.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
private boolean duplicates;
@@ -267,14 +286,14 @@ public final class CrmAction
try
{
QRecord row = requireRow(action, input, id);
String reason = check(action, input, row);
String reason = check(action, input, row, action.operation().equals("email") ? emailTransport() : null);
if(reason != null)
{
add(lines, Status.WARNING, reason, reason, id);
continue;
}
row.setValue("mergeSecondaryId", reviewed.getValue("mergeSecondaryId"));
String skipped = perform(action, input, row);
String skipped = perform(action, input, row, action.operation().equals("email") ? emailTransport() : null);
if(skipped == null)
{
add(lines, Status.OK, "will be " + action.past(), "was " + action.past(), id);
@@ -345,7 +364,7 @@ public final class CrmAction
/*******************************************************************************
** Shared read-only checks for Review and Result.
*******************************************************************************/
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
{
for(QFieldMetaData field : CrmActions.inputFields(action))
{
@@ -365,15 +384,7 @@ public final class CrmAction
switch(action.operation())
{
case "email":
try
{
Class.forName("com.kingsrook.qbits.crm.email.CrmEmailTransport");
return ("Email transport is present; sending integration is not configured for this action yet");
}
catch(ClassNotFoundException expected)
{
return (EMAIL_WARNING);
}
return (CrmEmails.skipReason(row, transport));
case "subscribe", "unsubscribe":
if(!StringUtils.hasContent(row.getValueString("email")))
{
@@ -479,7 +490,7 @@ public final class CrmAction
/*******************************************************************************
** Invoke the existing business operations only at Load time.
*******************************************************************************/
private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row, CrmEmailTransport transport) throws QException
{
RunBackendStepInput call = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(new HashMap<>());
for(QFieldMetaData field : CrmActions.inputFields(action))
@@ -494,6 +505,16 @@ public final class CrmAction
RunBackendStepOutput result = new RunBackendStepOutput();
switch(action.operation())
{
case "email" ->
{
String processUUID = input.getValueString("crmEmailProcessUUID");
if(processUUID == null)
{
throw (new QUserFacingException("Email process identity is missing; start the Send Email action again"));
}
CrmEmails.send(transport, row, input.getValueInteger("emailTemplateId"), input.getValueString("subject"), input.getValueString("body"),
"email", id + ":" + processUUID, null);
}
case "assign" -> update(action.table(), new QRecord().withValue("id", id).withValue("ownerUserId", input.getValue("newOwnerUserId")));
case "auto" ->
{
@@ -148,6 +148,12 @@ public class CrmActions implements MetaDataProducerInterface<MetaDataProducerMul
List<QFieldMetaData> fields = new ArrayList<>();
switch(action.operation())
{
case "email" ->
{
fields.add(field("emailTemplateId", "Template", QFieldType.INTEGER, "crm_email_template").withIsRequired(false));
fields.add(field("subject", "Subject", QFieldType.STRING, null).withIsRequired(false));
fields.add(field("body", "Body", QFieldType.TEXT, null).withIsRequired(false));
}
case "assign" -> fields.add(field("newOwnerUserId", "Owner", QFieldType.STRING, null));
case "subscribe", "unsubscribe" -> fields.add(field("emailListId", "List", QFieldType.INTEGER, "crm_email_list"));
case "enroll", "unenroll" -> fields.add(field("sequenceId", "Sequence", QFieldType.INTEGER, "crm_email_sequence"));
@@ -27,7 +27,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
@@ -288,13 +287,14 @@ public class CrmIntakeApi
QRecord contact;
if(contacts.isEmpty())
{
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource();
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource().strip();
source = source.length() > 100 ? source.substring(0, 100) : source;
List<QRecord> sources = store.by("crm_lead_source", "name", source);
QRecord leadSource = sources.isEmpty() ? store.insert("crm_lead_source", new QRecord().withValue("name", source).withValue("sortOrder", 0)) : sources.get(0);
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
.withValue("phone", request.phone()).withValue("ownerUserId", null)
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
}
else
@@ -323,7 +323,7 @@ public class CrmIntakeApi
}
update.withValue("lastTouchDate", Instant.now());
store.update("crm_contact", update);
if(contacts.isEmpty() && isUnassigned(contact.getValueString("ownerUserId")))
if(contacts.isEmpty() && isUnassigned(store.get("crm_contact", contact.getValueInteger("id")).getValueString("ownerUserId")))
{
CrmAutoAssign.assign(CrmAssignableEntity.CONTACT, contact.getValueInteger("id"));
}
@@ -395,6 +395,6 @@ public class CrmIntakeApi
*******************************************************************************/
private String name(String value)
{
return (value == null || value.isBlank() ? "Not provided" : value);
return (value == null || value.isBlank() ? null : value.strip());
}
}
@@ -0,0 +1,86 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.bulkload;
import java.io.IOException;
import java.io.InputStream;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfile;
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
/*******************************************************************************
** Starter mappings for the host's core bulk loader. The host owns its tables.
*******************************************************************************/
public final class CrmBulkLoadProfiles
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmBulkLoadProfiles()
{
}
/*******************************************************************************
** Call once during host startup, after core profile tables are installed.
** Existing profiles, including user edits, are preserved by label.
*******************************************************************************/
public static void ensure() throws QException
{
if(QContext.getQInstance().getTable(SavedBulkLoadProfile.TABLE_NAME) == null)
{
return;
}
try(InputStream stream = CrmBulkLoadProfiles.class.getResourceAsStream("/bulkload/crm-profiles.json"))
{
ObjectMapper mapper = new ObjectMapper();
for(JsonNode profile : mapper.readTree(stream))
{
String label = profile.get("label").asText();
if(!new QueryAction().execute(new QueryInput(SavedBulkLoadProfile.TABLE_NAME).withFilter(new QQueryFilter(
new QFilterCriteria("label", QCriteriaOperator.EQUALS, label)).withLimit(1))).getRecords().isEmpty())
{
continue;
}
BulkLoadProfile mapping = mapper.treeToValue(profile.get("mapping"), BulkLoadProfile.class);
CrmRecords.insert(SavedBulkLoadProfile.TABLE_NAME, new QRecord().withValue("label", label)
.withValue("tableName", profile.get("tableName").asText()).withValue("isBulkEdit", false)
.withValue("mappingJson", mapper.writeValueAsString(mapping)));
}
}
catch(IOException | IllegalArgumentException e)
{
throw (new QException("Could not load CRM bulk-load profiles", e));
}
}
}
@@ -0,0 +1,72 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.core;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Checked writes for background work: record errors must not advance checkpoints.
*******************************************************************************/
public final class CrmRecords
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmRecords()
{
}
/*******************************************************************************
** Insert and require a successful persisted row.
*******************************************************************************/
public static QRecord insert(String table, QRecord row) throws QException
{
return (check(new InsertAction().execute(new InsertInput(table).withRecord(row)).getRecords()));
}
/*******************************************************************************
** Update and require a successful persisted row.
*******************************************************************************/
public static QRecord update(String table, QRecord row) throws QException
{
return (check(new UpdateAction().execute(new UpdateInput(table).withRecord(row)).getRecords()));
}
/*******************************************************************************
** DML errors are failed work, never successful job progress.
*******************************************************************************/
private static QRecord check(List<QRecord> rows) throws QException
{
if(rows.isEmpty() || rows.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
{
throw (new QException("CRM write was rejected"));
}
return (rows.getFirst());
}
}
@@ -0,0 +1,73 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.core.customizers;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** RC.6 recordLabelFormat is printf-only; preserve a computed query/get label.
*******************************************************************************/
public class ContactLabelCustomizer extends AbstractPostQueryCustomizer
{
/*******************************************************************************
** Keep missing names absent in storage while giving records useful labels.
*******************************************************************************/
@Override
public List<QRecord> apply(List<QRecord> records)
{
for(QRecord record : records)
{
QRecord visible = new QRecord().withValue("id", record.getValue("id"));
for(String name : List.of("firstName", "lastName", "email"))
{
var field = QContext.getQInstance().getTable(Contact.TABLE_NAME).getField(name);
if(FieldSecurityLockHelper.isFieldAccessible(field)
&& !(input != null && input.getShouldOmitHiddenFields() && field.getIsHidden())
&& !(input != null && input.getShouldMaskPasswords() && field.getType().needsMasked()))
{
visible.setValue(name, record.getValue(name));
}
}
record.setRecordLabel(label(visible));
}
return (records);
}
/*******************************************************************************
** Prefer the supplied name, then email, then the record identifier.
*******************************************************************************/
public static String label(QRecord record)
{
String name = (Objects.toString(record.getValue("firstName"), "").strip() + " "
+ Objects.toString(record.getValue("lastName"), "").strip()).strip();
return (name.isBlank() ? Objects.toString(record.getValue("email"), Objects.toString(record.getValue("id"), "Contact")) : name);
}
}
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType;
import com.kingsrook.qbits.crm.deals.model.Deal;
@@ -46,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
/*******************************************************************************
@@ -108,30 +104,7 @@ public class Company extends QRecordEntity
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
///////////////////////////////////////////////////////////////
// inject security fields and locks from CrmQBitConfig //
///////////////////////////////////////////////////////////////
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
if(config != null)
{
if(config.getSecurityFields() != null)
{
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
{
table.addField(fieldMetaData.clone());
t1Section.getFieldNames().add(fieldMetaData.getName());
}
}
if(config.getRecordSecurityLocks() != null)
{
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
table.withRecordSecurityLock(lock);
}
}
}
return (table);
}
@@ -28,9 +28,11 @@ package com.kingsrook.qbits.crm.core.model;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
import com.kingsrook.qbits.crm.privacy.CrmPersonalData.ErasureFlag;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer;
@@ -111,6 +113,12 @@ public class ConsentRecord extends QRecordEntity
@Override
public List<QRecord> apply(List<QRecord> records) throws QException
{
if(updateInput != null && updateInput.hasFlag(ErasureFlag.REDACT_CONSENT)
&& records.stream().allMatch(row -> Set.of("id", "consentText").containsAll(row.getValues().keySet())
&& "erased".equals(row.getValueString("consentText"))))
{
return (records);
}
throw new QUserFacingException("Consent records cannot be modified or deleted");
}
}
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.model;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.email.model.EmailMessage;
@@ -46,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
/*******************************************************************************
@@ -105,51 +101,25 @@ public class Contact extends QRecordEntity
@Override
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
{
QFieldSection t1Section = SectionFactory.defaultT1("id", "firstName", "lastName", "email", "phone");
QFieldSection t1Section = SectionFactory.defaultT1("firstName", "lastName", "email", "phone");
table
.withIcon(new QIcon().withName("person"))
.withRecordLabelFormat("%s %s")
.withRecordLabelFields("firstName", "lastName")
.withRecordLabelFormat(null)
.withRecordLabelFields(List.of())
.withSection(t1Section)
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
List.of("companyId", "jobTitle", "department", "lifecycleStageId", "leadSourceId",
"ownerUserId", "leadScore", "timezone", "preferredLanguage")))
.withSection(new QFieldSection("online", new QIcon().withName("language"), Tier.T2,
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl")))
.withSection(new QFieldSection("address", new QIcon().withName("location_on"), Tier.T2,
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
"ownerUserId", "leadScore", "timezone", "preferredLanguage", "description")))
.withSection(new QFieldSection("contactInformation", "Contact Information", new QIcon("contact_mail"), Tier.T2,
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl", "addressLine1", "addressLine2", "city", "state", "postalCode", "country",
"doNotEmail", "doNotCall", "unsubscribeDate")))
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
.withSection(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3,
List.of("id", "lastActivityDate", "lastContactedDate", "createDate", "modifyDate")));
///////////////////////////////////////////////////////////////
// inject security fields and locks from CrmQBitConfig //
///////////////////////////////////////////////////////////////
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
if(config != null)
{
if(config.getSecurityFields() != null)
{
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
{
table.addField(fieldMetaData.clone());
t1Section.getFieldNames().add(fieldMetaData.getName());
}
}
if(config.getRecordSecurityLocks() != null)
{
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
table.withRecordSecurityLock(lock);
}
}
}
return (table);
}
@@ -157,19 +127,19 @@ public class Contact extends QRecordEntity
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmSource;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmMedium;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmCampaign;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmContent;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstTouchUtmTerm;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -181,19 +151,19 @@ public class Contact extends QRecordEntity
@QField()
private Instant firstTouchDate;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmSource;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmMedium;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmCampaign;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmContent;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastTouchUtmTerm;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -208,16 +178,16 @@ public class Contact extends QRecordEntity
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String firstName;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String lastName;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String email;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String secondaryEmail;
@QField(maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -241,7 +211,7 @@ public class Contact extends QRecordEntity
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
private Integer leadSourceId;
@QField(isRequired = true)
@QField()
private String ownerUserId;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -123,7 +123,7 @@ public class EmailListMember extends QRecordEntity
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String email;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -129,7 +129,7 @@ public class FormSubmission extends QRecordEntity
@QField(isRequired = true, possibleValueSourceName = CrmFormType.NAME)
private Integer formType;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String email;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -150,19 +150,19 @@ public class FormSubmission extends QRecordEntity
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String sourceUrl;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmSource;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmMedium;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmCampaign;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmTerm;
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String utmContent;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -40,7 +40,13 @@ public enum CrmEmailStatus implements PossibleValueEnum<Integer>
DRAFT(1, "Draft"),
SENT(2, "Sent"),
DELIVERED(3, "Delivered"),
FAILED(4, "Failed");
FAILED(4, "Failed"),
QUEUED(5, "Queued"),
BOUNCED(6, "Bounced"),
COMPLAINED(7, "Complained"),
OPENED(8, "Opened"),
CLICKED(9, "Clicked"),
SUPPRESSED(10, "Suppressed");
private final Integer id;
private final String label;
@@ -18,147 +18,30 @@
* limitations under the License.
*/
/*******************************************************************************
** GDPR right-to-erasure process. Anonymizes PII on a contact, scrubs audit
** log entries, deletes attachments, unenrolls from sequences, creates a
** consent-withdrawn record, and logs the anonymization event.
*******************************************************************************/
package com.kingsrook.qbits.crm.core.processes;
import java.time.Instant;
import com.kingsrook.qbits.crm.core.model.ConsentRecord;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.privacy.CrmPersonalData;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Backend operation used by the streamed CRM actions for GDPR anonymization of a contact.
** Backend operation for the streamed Erase Personal Data action.
*******************************************************************************/
public class GdprAnonymizeContactProcess implements BackendStep
{
private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class);
public static final String NAME = "crm_gdpr_anonymize_contact";
private static final String ANONYMIZED = "[ANONYMIZED]";
/*******************************************************************************
** Execute the GDPR anonymization process.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Integer contactId = input.getValueInteger("contactId");
///////////////////////////////////////////
// Step 1: load and validate contact //
///////////////////////////////////////////
QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId);
if(contactRecord == null)
{
throw new QException("Contact not found: " + contactId);
}
///////////////////////////////////////////
// Step 2: anonymize PII fields //
///////////////////////////////////////////
QRecord updateRecord = new QRecord()
.withValue("id", contactId)
.withValue("firstName", ANONYMIZED)
.withValue("lastName", ANONYMIZED)
.withValue("email", "anonymized-" + contactId + "@removed.invalid")
.withValue("phone", null)
.withValue("mobilePhone", null)
.withValue("secondaryEmail", null)
.withValue("addressLine1", null)
.withValue("addressLine2", null)
.withValue("city", null)
.withValue("state", null)
.withValue("postalCode", null)
.withValue("country", null)
.withValue("website", null)
.withValue("linkedinUrl", null)
.withValue("description", null)
.withValue("doNotEmail", true)
.withValue("doNotCall", true);
new UpdateAction().execute(
new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord));
///////////////////////////////////////////
// Step 3: unenroll from sequences //
///////////////////////////////////////////
unenrollFromSequences(contactId);
///////////////////////////////////////////
// Step 4: create consent record //
///////////////////////////////////////////
new InsertAction().execute(
new InsertInput(ConsentRecord.TABLE_NAME).withRecordEntity(
new ConsentRecord()
.withContactId(contactId)
.withConsentType(CrmConsentType.DATA_PROCESSING.getPossibleValueId())
.withStatus(CrmConsentStatus.WITHDRAWN.getPossibleValueId())
.withSource("GDPR Anonymization Process")
.withConsentDate(Instant.now())));
Integer contactId = input.getValueInteger("contactId");
CrmPersonalData.erase(contactId);
output.addValue("contactId", contactId);
output.addValue("anonymized", true);
}
/***************************************************************************
** Unenroll from any active sequence enrollments for this contact.
***************************************************************************/
private void unenrollFromSequences(Integer contactId) throws QException
{
try
{
QueryOutput enrollments = new QueryAction().execute(
new QueryInput("crm_sequence_enrollment")
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))));
for(QRecord record : enrollments.getRecords())
{
QRecord updateRecord = new QRecord()
.withValue("id", record.getValueInteger("id"))
.withValue("status", CrmEnrollmentStatus.UNENROLLED.getPossibleValueId());
new UpdateAction().execute(
new UpdateInput("crm_sequence_enrollment").withRecord(updateRecord));
}
}
catch(Exception e)
{
LOG.warn("Error unenrolling contact from sequences during GDPR anonymization", e);
}
}
}
@@ -0,0 +1,108 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.deals;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Deal age notifications with a persistent once-per-deal-and-stage checkpoint.
*******************************************************************************/
public final class CrmDealNotifications
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmDealNotifications()
{
}
/*******************************************************************************
** Scan open deals in keyset pages, resolving stage metadata once per run.
*******************************************************************************/
public static void checkRotting() throws QException
{
Map<Integer, QRecord> stages = new HashMap<>();
for(QRecord stage : new QueryAction().execute(new QueryInput("crm_pipeline_stage")).getRecords())
{
stages.put(stage.getValueInteger("id"), stage);
}
Integer lastId = null;
Instant now = Instant.now();
while(true)
{
QQueryFilter filter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_deal").withFilter(filter)).getRecords();
for(QRecord deal : page)
{
lastId = deal.getValueInteger("id");
QRecord stage = stages.get(deal.getValueInteger("pipelineStageId"));
Instant entered = deal.getValueInstant("stageEnteredDate");
if(stage == null || entered == null || deal.getValueString("ownerUserId") == null
|| Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")))
{
continue;
}
Integer days = stage.getValueInteger("rotDays");
if(days == null || days <= 0 || !entered.isBefore(now.minus(days, ChronoUnit.DAYS)))
{
continue;
}
String key = "deal:" + lastId + ":stage:" + stage.getValue("id");
String checkpoint = "crm.deal.rotting:" + key;
if(new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, checkpoint)))).getRecords().isEmpty())
{
QAlerts.notify(new NotificationSignal("Deal needs attention").withSourceName("crm.deal.rotting").withSourceKey(key)
.withSourceTableName("crm_deal").withSourceRecordId(lastId.toString())
.withAudience(NotificationAudience.user(deal.getValueString("ownerUserId"))));
CrmRecords.insert(JobState.TABLE_NAME, new QRecord().withValue("name", checkpoint).withValue("lastSuccess", now));
}
}
if(page.size() < 200)
{
break;
}
}
}
}
@@ -31,6 +31,7 @@ package com.kingsrook.qbits.crm.deals.customizers;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import com.kingsrook.qbits.crm.CrmSessionUtils;
import com.kingsrook.qbits.crm.deals.model.Deal;
@@ -130,6 +131,13 @@ public class DealInitializationCustomizer extends AbstractPostInsertCustomizer
}
}
if(stage != null && (Boolean.TRUE.equals(stage.getIsClosedWon()) || Boolean.TRUE.equals(stage.getIsClosedLost()))
&& record.getValue("actualCloseDate") == null)
{
updateRecord.setValue("actualCloseDate", now.atZone(ZoneOffset.UTC).toLocalDate());
needsUpdate = true;
}
///////////////////////////////////////////
// calculate weightedAmount //
///////////////////////////////////////////
@@ -51,6 +51,9 @@ import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
@@ -192,6 +195,14 @@ public class DealStageChangeCustomizer extends AbstractPreUpdateCustomizer
// insert DealStageHistory row //
///////////////////////////////////////////
insertStageHistoryRow(record, oldRecord, oldStageId, newStageId, now);
String owner = record.getValues().containsKey("ownerUserId") ? record.getValueString("ownerUserId") : oldRecord.getValueString("ownerUserId");
if(Boolean.TRUE.equals(newStage.getIsClosedWon()) && owner != null)
{
QAlerts.notify(new NotificationSignal("Deal won").withSourceName("crm.deal.won")
.withSourceKey("deal:" + record.getValue("id") + ":stage:" + newStageId + ":" + now)
.withSourceTableName(Deal.TABLE_NAME).withSourceRecordId(record.getValueString("id"))
.withAudience(NotificationAudience.user(owner)), updateInput == null ? null : updateInput.getTransaction());
}
}
@@ -30,7 +30,6 @@ import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
@@ -52,13 +51,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
/*******************************************************************************
@@ -125,30 +121,7 @@ public class Deal extends QRecordEntity
.withSection(SectionFactory.defaultT2("description"))
.withSection(SectionFactory.defaultT3("stageEnteredDate", "lastActivityDate", "createDate", "modifyDate"));
///////////////////////////////////////////////////////////////
// inject security fields and locks from CrmQBitConfig //
///////////////////////////////////////////////////////////////
CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig();
if(config != null)
{
if(config.getSecurityFields() != null)
{
t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames()));
for(QFieldMetaData fieldMetaData : config.getSecurityFields())
{
table.addField(fieldMetaData.clone());
t1Section.getFieldNames().add(fieldMetaData.getName());
}
}
if(config.getRecordSecurityLocks() != null)
{
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
table.withRecordSecurityLock(lock);
}
}
}
///////////////////////////////////////////////////////////
// Only offer stages belonging to the selected pipeline. //
@@ -156,6 +129,9 @@ public class Deal extends QRecordEntity
table.getField("pipelineStageId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}")));
table.getField("primaryContactId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("companyId", QCriteriaOperator.EQUALS, "${input.companyId}")));
return (table);
}
}
@@ -195,7 +171,7 @@ public class Deal extends QRecordEntity
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer primaryContactId;
@QField(isRequired = true)
@QField()
private String ownerUserId;
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
@@ -0,0 +1,55 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.email;
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
/*******************************************************************************
** Carry the core process UUID across Review/Result and retries in process state.
*******************************************************************************/
public class CrmEmailProcessIdentity implements ProcessLifecycleListenerInterface
{
@Override
public boolean appliesTo(String processName)
{
return ("crm_send_email".equals(processName));
}
@Override
public void onProcessStarted(RunProcessInput input)
{
input.getProcessState().getValues().put("crmEmailProcessUUID", input.getProcessUUID());
}
@Override
public void onProcessCompleted(RunProcessInput input, RunProcessOutput output)
{
}
@Override
public void onProcessFailed(RunProcessInput input, Exception exception)
{
}
}
@@ -21,8 +21,11 @@
package com.kingsrook.qbits.crm.email;
import java.time.Instant;
import java.util.Optional;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiEffectOutcome;
@@ -87,6 +90,14 @@ public class CrmEmailTransport
** Deliver one message, using a stable CRM purpose/id key for the durable email outbox.
*******************************************************************************/
public void send(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
deliver(purpose, id, contactId, address, subject, body, marketing);
}
/*******************************************************************************
** Return the transport result without guessing that queued email was sent.
*******************************************************************************/
private Delivery deliver(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
if(emailInstalled())
{
@@ -98,7 +109,9 @@ public class CrmEmailTransport
{
throw (new QUserFacingException("The email service could not accept this message."));
}
return;
var message = result.message();
return (message == null ? new Delivery(null, CrmEmailStatus.QUEUED, null, null, null, null, null)
: new Delivery(String.valueOf(message.id()), status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null));
}
if(readiness() != CrmReadiness.CORE_MESSAGING)
{
@@ -107,5 +120,52 @@ public class CrmEmailTransport
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName(config.getMessagingProviderName())
.withTo(new Party().withAddress(address).withRole(EmailPartyRole.TO)).withSubject(subject)
.withContent(new Content().withBody(body).withContentRole(EmailContentRole.TEXT)));
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
}
/*******************************************************************************
** CRM-owned delivery snapshot; optional email types never escape this adapter.
*******************************************************************************/
public record Delivery(String messageId, CrmEmailStatus status, Instant sentDate, Instant deliveredDate,
Instant openedDate, Instant clickedDate, Instant bouncedDate)
{
}
/*******************************************************************************
** Send and return the actual outbox state, or the successful core send state.
*******************************************************************************/
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
send(purpose, id, contactId, address, subject, body, marketing);
return (lookup("crm:" + purpose + ":" + id).orElseGet(() ->
new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null)));
}
/*******************************************************************************
** Read delivery through the published API only. RC.3 exposes sent/delivered dates.
*******************************************************************************/
public Optional<Delivery> lookup(String key) throws QException
{
if(!emailInstalled() || key == null)
{
return (Optional.empty());
}
return (EmailApi.getMessageByIdempotencyKey(key).map(message -> new Delivery(String.valueOf(message.id()),
status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null)));
}
/*******************************************************************************
** Preserve known delivery states and leave unknown/pending states eligible for sync.
*******************************************************************************/
private CrmEmailStatus status(String status)
{
try
{
return (CrmEmailStatus.valueOf(status));
}
catch(IllegalArgumentException | NullPointerException e)
{
return (CrmEmailStatus.QUEUED);
}
}
}
@@ -0,0 +1,167 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.email;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.api.ActivityKind;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Shared CRM template rendering, transport delivery and timeline recording.
*******************************************************************************/
public final class CrmEmails
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmEmails()
{
}
/*******************************************************************************
** A skip reason suitable for standard WARNING summary lines.
*******************************************************************************/
public static String skipReason(QRecord contact, CrmEmailTransport transport) throws QException
{
if(Boolean.TRUE.equals(contact.getValueBoolean("doNotEmail")))
{
return ("Contact has opted out of email");
}
if(contact.getValueString("email") == null || contact.getValueString("email").isBlank())
{
return ("Contact has no email address");
}
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED ? "CRM email is not configured" : null);
}
/*******************************************************************************
** Send once using the caller's stable purpose/id and record the transport outcome.
*******************************************************************************/
public static void send(CrmEmailTransport transport, QRecord contact, Integer templateId, String subject, String body,
String purpose, String id, Integer enrollmentId) throws QException
{
String key = "crm:" + purpose + ":" + id;
List<QRecord> existing = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(
new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.EQUALS, key)))).getRecords();
if(!existing.isEmpty())
{
recordActivity(existing.getFirst());
return;
}
if(templateId != null)
{
QRecord template = GetAction.execute("crm_email_template", templateId);
if(template == null)
{
throw (new QUserFacingException("Email template is unavailable"));
}
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
if(body == null)
{
body = template.getValueString("bodyHtml");
}
}
if(subject == null || subject.isBlank() || body == null || body.isBlank())
{
throw (new QUserFacingException("Email subject and body are required"));
}
subject = render(subject, contact);
body = render(body, contact);
CrmEmailTransport.Delivery delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
contact.getValueString("email"), subject, body, enrollmentId != null);
QRecord message = CrmRecords.insert("crm_email_message", new QRecord().withValue("contactId", contact.getValue("id"))
.withValue("emailTemplateId", templateId).withValue("sequenceEnrollmentId", enrollmentId)
.withValue("toAddresses", contact.getValue("email")).withValue("subject", subject)
.withValue("direction", CrmDirection.OUTBOUND.getId()).withValue("status", delivery.status().getId())
.withValue("idempotencyKey", key).withValue("emailMessageId", delivery.messageId())
.withValue("sentDate", delivery.sentDate()).withValue("deliveredDate", delivery.deliveredDate()));
recordActivity(message);
}
/*******************************************************************************
** Repair a partially recorded send without sending again or duplicating its activity.
*******************************************************************************/
private static void recordActivity(QRecord message) throws QException
{
if(message.getValue("activityId") != null)
{
return;
}
String description = "Email: " + message.getValueString("idempotencyKey");
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, message.getValue("contactId")),
new QFilterCriteria("description", QCriteriaOperator.EQUALS, description));
List<QRecord> activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
if(activities.isEmpty())
{
new CrmMarketingApi().recordActivity(message.getValueInteger("contactId"),
message.getValue("sequenceEnrollmentId") == null ? ActivityKind.EMAIL : ActivityKind.SEQUENCE_STEP,
message.getValueString("subject"), description);
activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
}
CrmRecords.update("crm_email_message", new QRecord().withValue("id", message.getValue("id"))
.withValue("activityId", activities.getFirst().getValue("id")));
}
/*******************************************************************************
** Literal replacements avoid treating customer data as a template program.
*******************************************************************************/
public static String render(String text, QRecord contact) throws QException
{
QRecord company = contact.getValue("companyId") == null ? null : GetAction.execute("crm_company", contact.getValue("companyId"));
Map<String, String> fields = Map.of("contact.firstName", value(contact, "firstName"), "contact.lastName", value(contact, "lastName"),
"company.name", value(company, "name"), "owner.name", value(contact, "ownerUserId"));
// Replace each token in the original text only, so inserted values cannot introduce merge fields.
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}").matcher(text);
StringBuilder rendered = new StringBuilder();
while(matcher.find())
{
String replacement = "unsubscribeUrl".equals(matcher.group(1))
? new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null) : fields.get(matcher.group(1));
matcher.appendReplacement(rendered, java.util.regex.Matcher.quoteReplacement(replacement));
}
matcher.appendTail(rendered);
return (rendered.toString());
}
/*******************************************************************************
** Missing merge values render as empty strings.
*******************************************************************************/
private static String value(QRecord row, String field)
{
return (row == null || row.getValueString(field) == null ? "" : row.getValueString(field));
}
}
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
@@ -75,6 +76,7 @@ public class EmailMessage extends QRecordEntity
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
{
table
.withUniqueKey(new UniqueKey("idempotencyKey"))
.withIcon(new QIcon().withName("email"))
.withRecordLabelFormat("%s")
.withRecordLabelFields("subject")
@@ -85,10 +87,10 @@ public class EmailMessage extends QRecordEntity
List.of("bodyHtml", "bodyText")))
.withSection(new QFieldSection("tracking", new QIcon("visibility"), Tier.T2,
List.of("isTracked", "openCount", "clickCount", "firstOpenedDate", "firstClickedDate",
"bounceType", "isSpamComplaint")))
"bounceType", "isSpamComplaint", "deliveredDate", "bouncedDate")))
.withSection(new QFieldSection("references", new QIcon("link"), Tier.T2,
List.of("contactId", "activityId", "emailTemplateId", "sequenceEnrollmentId",
"threadId", "replyToEmailId")))
"threadId", "replyToEmailId", "idempotencyKey", "emailMessageId")))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
@@ -106,7 +108,7 @@ public class EmailMessage extends QRecordEntity
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String fromAddress;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -172,6 +174,18 @@ public class EmailMessage extends QRecordEntity
@QField()
private Integer replyToEmailId;
@QField(maxLength = 250)
private String idempotencyKey;
@QField(maxLength = 100)
private String emailMessageId;
@QField()
private Instant deliveredDate;
@QField()
private Instant bouncedDate;
@QField(isEditable = false)
private Instant createDate;
@@ -1031,4 +1045,103 @@ public class EmailMessage extends QRecordEntity
return (this);
}
/*******************************************************************************
** Get idempotencyKey.
*******************************************************************************/
public String getIdempotencyKey()
{
return (idempotencyKey);
}
/*******************************************************************************
** Set idempotencyKey.
*******************************************************************************/
public EmailMessage withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Get emailMessageId.
*******************************************************************************/
public String getEmailMessageId()
{
return (emailMessageId);
}
/*******************************************************************************
** Set emailMessageId.
*******************************************************************************/
public EmailMessage withEmailMessageId(String emailMessageId)
{
this.emailMessageId = emailMessageId;
return (this);
}
/*******************************************************************************
** Get deliveredDate.
*******************************************************************************/
public Instant getDeliveredDate()
{
return (deliveredDate);
}
/*******************************************************************************
** Set deliveredDate.
*******************************************************************************/
public EmailMessage withDeliveredDate(Instant deliveredDate)
{
this.deliveredDate = deliveredDate;
return (this);
}
/*******************************************************************************
** Get bouncedDate.
*******************************************************************************/
public Instant getBouncedDate()
{
return (bouncedDate);
}
/*******************************************************************************
** Set bouncedDate.
*******************************************************************************/
public EmailMessage withBouncedDate(Instant bouncedDate)
{
this.bouncedDate = bouncedDate;
return (this);
}
/*******************************************************************************
** Set idempotencyKey for entity mapping.
*******************************************************************************/
public void setIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
}
/*******************************************************************************
** Set emailMessageId for entity mapping.
*******************************************************************************/
public void setEmailMessageId(String emailMessageId)
{
this.emailMessageId = emailMessageId;
}
/*******************************************************************************
** Set deliveredDate for entity mapping.
*******************************************************************************/
public void setDeliveredDate(Instant deliveredDate)
{
this.deliveredDate = deliveredDate;
}
/*******************************************************************************
** Set bouncedDate for entity mapping.
*******************************************************************************/
public void setBouncedDate(Instant bouncedDate)
{
this.bouncedDate = bouncedDate;
}
}
@@ -0,0 +1,104 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.email.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** Scheduled CRM maintenance: crm_expire_consents.
*******************************************************************************/
public class ExpireConsentsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_expire_consents";
@Override
public QProcessMetaData produce(QInstance instance)
{
return (new QProcessMetaData().withName(NAME).withLabel("ExpireConsents").withIsHidden(true)
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(ExpireConsentsProcess.class))));
}
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Instant now = Instant.now();
Instant cutoff = now.minus(14, ChronoUnit.DAYS);
Integer lastId = null;
int expired = 0;
while(true)
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmListMemberStatus.PENDING_CONFIRMATION.getId()))
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_list_member").withFilter(filter)).getRecords();
for(QRecord member : page)
{
lastId = member.getValueInteger("id");
Instant pendingSince = member.getValueInstant("confirmationSentDate");
if(pendingSince == null)
{
pendingSince = member.getValueInstant("createDate");
}
if(pendingSince != null && !pendingSince.isAfter(cutoff))
{
CrmRecords.update("crm_email_list_member", new QRecord().withValue("id", lastId)
.withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()).withValue("confirmationTokenHash", null)
.withValue("unsubscribedDate", now));
expired++;
}
}
if(page.size() < 200)
{
break;
}
}
output.addValue("expiredPendingCount", expired);
output.addValue("expiredConsentCount", new CountAction().execute(new CountInput("crm_consent_record")
.withFilter(new QQueryFilter(new QFilterCriteria("expiryDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)))).getCount());
}
}
@@ -34,9 +34,15 @@ import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityType;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.email.CrmEmails;
import com.kingsrook.qbits.crm.email.model.EmailSequence;
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
import com.kingsrook.qbits.crm.email.model.SequenceStep;
@@ -45,6 +51,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
@@ -79,6 +87,15 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
private static final int MAX_FAILURES = 3;
/*******************************************************************************
** Resolve the host transport; tests may supply a fake.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
/*******************************************************************************
@@ -92,7 +109,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
.withIsHidden(true)
.withLabel("Process Sequence Steps")
.withIcon(new QIcon().withName("play_circle"))
// Scheduling is configured by the host application via ScheduledJob records
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
@@ -111,6 +127,18 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
///////////////////////////////////////////////////////////
// query enrollments where status=ACTIVE and due now //
///////////////////////////////////////////////////////////
CrmEmailTransport transport = emailTransport();
boolean ready = transport.readiness() != CrmReadiness.NOT_CONFIGURED;
boolean activeSequences = !new QueryAction().execute(new QueryInput(EmailSequence.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withLimit(1))).getRecords().isEmpty();
if(!ready && activeSequences)
{
QAlerts.raise(new AlertSignal("crm.email.unavailable").withDedupKey("transport").withTitle("CRM email is unavailable"));
}
else if(ready)
{
QAlerts.resolve("crm.email.unavailable", "transport", "Email transport is ready");
}
int processedCount = 0;
Integer lastId = null;
Instant dueThrough = Instant.now();
@@ -130,7 +158,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
try
{
processEnrollment(enrollment);
processEnrollment(enrollment, transport);
processedCount++;
}
catch(Exception e)
@@ -154,7 +182,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
** Process a single enrollment: load the next step and execute it.
** Respects businessDaysOnly and sendWindow settings on the sequence.
***************************************************************************/
private void processEnrollment(SequenceEnrollment enrollment) throws QException
private void processEnrollment(SequenceEnrollment enrollment, CrmEmailTransport transport) throws QException
{
Integer nextStepNumber = enrollment.getCurrentStepNumber() + 1;
@@ -167,6 +195,11 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
EmailSequence sequence = sequenceGet.getRecord() != null
? new EmailSequence(sequenceGet.getRecord()) : null;
if(sequence == null || !Boolean.TRUE.equals(sequence.getIsActive()))
{
return;
}
/////////////////////////////////////////////
// load the contact for timezone //
/////////////////////////////////////////////
@@ -237,7 +270,27 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
{
switch(stepType)
{
case EMAIL -> executeEmailStep(enrollment, step);
case EMAIL ->
{
var eligibility = new CrmMarketingApi().checkEmailEligibility(enrollment.getContactId());
if(!eligibility.eligible())
{
CrmRecords.update(SequenceEnrollment.TABLE_NAME, new QRecord().withValue("id", enrollment.getId())
.withValue("status", CrmEnrollmentStatus.PAUSED.getId()).withValue("lastFailureMessage", eligibility.reason())
.withValue("nextStepDate", null));
return;
}
if(transport.readiness() == CrmReadiness.NOT_CONFIGURED)
{
return;
}
if(step.getEmailTemplateId() == null)
{
throw (new QException("Sequence email template is required"));
}
CrmEmails.send(transport, contactGet.getRecord(), step.getEmailTemplateId(), null, null,
"seq", enrollment.getId() + ":" + step.getId(), enrollment.getId());
}
case CALL -> executeCallStep(enrollment, step);
case TASK -> executeTaskStep(enrollment, step);
case WAIT ->
@@ -257,50 +310,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
/***************************************************************************
** Execute an EMAIL step: verify doNotEmail, create an activity record.
***************************************************************************/
private void executeEmailStep(SequenceEnrollment enrollment, SequenceStep step) throws QException
{
///////////////////////////////////////////////
// check doNotEmail //
///////////////////////////////////////////////
GetOutput contactGet = new GetAction().execute(
new GetInput(Contact.TABLE_NAME).withPrimaryKey(enrollment.getContactId()));
if(contactGet.getRecord() != null)
{
Contact contact = new Contact(contactGet.getRecord());
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
{
throw (new QException("Contact has opted out of email"));
}
}
///////////////////////////////////////////////
// create activity for the email //
///////////////////////////////////////////////
String subject = "Sequence email: step " + step.getStepNumber();
if(step.getEmailTemplateId() != null)
{
subject += " (template #" + step.getEmailTemplateId() + ")";
}
Integer activityTypeId = lookupActivityTypeByName("Email");
new InsertAction().execute(
new InsertInput(Activity.TABLE_NAME).withRecordEntity(
new Activity()
.withActivityTypeId(activityTypeId)
.withContactId(enrollment.getContactId())
.withDealId(enrollment.getDealId())
.withSubject(subject)
.withOwnerUserId("system")
.withIsCompleted(true)));
}
/***************************************************************************
** Execute a CALL step: create a task activity assigned to the contact owner.
***************************************************************************/
@@ -524,6 +533,8 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
{
updateRecord.withValue("status", CrmEnrollmentStatus.FAILED.getPossibleValueId());
updateRecord.withValue("nextStepDate", null);
QAlerts.raise(new AlertSignal("crm.sequence.failing").withDedupKey("enrollment:" + enrollment.getId())
.withTitle("Sequence enrollment failed three times"));
}
new UpdateAction().execute(
@@ -0,0 +1,153 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.email.processes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** Scheduled CRM maintenance: crm_sync_email_delivery.
*******************************************************************************/
public class SyncEmailDeliveryProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "crm_sync_email_delivery";
@Override
public QProcessMetaData produce(QInstance instance)
{
return (new QProcessMetaData().withName(NAME).withLabel("SyncEmailDelivery").withIsHidden(true)
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(SyncEmailDeliveryProcess.class))));
}
/*******************************************************************************
** Resolve the host transport; an absent qbit-email yields empty lookups.
*******************************************************************************/
protected CrmEmailTransport emailTransport() throws QException
{
return (new CrmEmailTransport(CrmApi.config()));
}
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CrmEmailTransport transport = emailTransport();
Integer lastId = null;
int updated = 0;
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
while(true)
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
if(lastId != null)
{
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(filter)).getRecords();
for(QRecord message : page)
{
lastId = message.getValueInteger("id");
if(message.getValueInteger("status") != null && List.of(CrmEmailStatus.BOUNCED.getId(), CrmEmailStatus.COMPLAINED.getId(), CrmEmailStatus.FAILED.getId(),
CrmEmailStatus.SUPPRESSED.getId()).contains(message.getValueInteger("status")))
{
continue;
}
var delivery = transport.lookup(message.getValueString("idempotencyKey"));
if(delivery.isEmpty())
{
continue;
}
CrmEmailTransport.Delivery state = delivery.get();
QRecord change = new QRecord().withValue("id", lastId).withValue("status", state.status().getId());
date(change, "sentDate", state.sentDate());
date(change, "deliveredDate", state.deliveredDate());
date(change, "firstOpenedDate", state.openedDate());
date(change, "firstClickedDate", state.clickedDate());
date(change, "bouncedDate", state.bouncedDate());
boolean complaint = state.status() == CrmEmailStatus.COMPLAINED;
if(complaint || state.status() == CrmEmailStatus.BOUNCED)
{
Integer contactId = message.getValueInteger("contactId");
if(contactId != null)
{
CrmRecords.update("crm_contact", new QRecord().withValue("id", contactId).withValue("doNotEmail", true));
String source = complaint ? "Complaint" : "Hard bounce";
// The terminal message checkpoint is written last. A retry must not duplicate consent evidence.
String evidence = "Email: " + message.getValueString("idempotencyKey");
List<QRecord> recorded = new QueryAction().execute(new QueryInput("crm_consent_record").withFilter(new QQueryFilter(
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId),
new QFilterCriteria("source", QCriteriaOperator.EQUALS, source),
new QFilterCriteria("consentText", QCriteriaOperator.EQUALS, evidence)))).getRecords();
if(recorded.isEmpty())
{
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
.withValue("consentType", CrmConsentType.EMAIL.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
.withValue("consentDate", Instant.now()).withValue("source", source).withValue("consentText", evidence));
}
}
change.withValue("isSpamComplaint", complaint);
}
CrmRecords.update("crm_email_message", change);
updated++;
}
if(page.size() < 200)
{
break;
}
}
output.addValue("updatedCount", updated);
}
/*******************************************************************************
** Never clear a timestamp when the provider has not exposed it.
*******************************************************************************/
private void date(QRecord row, String field, Instant value)
{
if(value != null)
{
row.setValue(field, value);
}
}
}
@@ -0,0 +1,192 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.health;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.health.HealthIndicator;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)
** CRM checks for hosts to register with their health middleware. Run in the
** caller's tenant context; no check elevates access or returns personal values.
*******************************************************************************/
public class CrmHealthChecks
{
private final CrmEmailTransport transport;
/*******************************************************************************
** Use the same readiness decision as actual email delivery.
*******************************************************************************/
public CrmHealthChecks(CrmQBitConfig config)
{
this(new CrmEmailTransport(config));
}
/*******************************************************************************
** Allow a host transport adapter.
*******************************************************************************/
public CrmHealthChecks(CrmEmailTransport transport)
{
this.transport = transport;
}
/*******************************************************************************
** Run all checks without registering tables or starting background workers.
*******************************************************************************/
public Map<String, HealthCheckResult> run() throws QException
{
return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore()));
}
/*******************************************************************************
** Expose individual checks through the RC.6 health SPI.
*******************************************************************************/
public List<HealthIndicator> indicators()
{
return (indicators(QContext.getQSession() == null ? new QSession() : QContext.getQSession()));
}
/*******************************************************************************
** Supply the host's monitoring session explicitly for middleware worker threads.
** Its permissions and tenant keys are preserved; this adapter never grants keys.
*******************************************************************************/
public List<HealthIndicator> indicators(QSession session)
{
return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session)));
}
/*******************************************************************************
** One independently evaluated middleware indicator.
*******************************************************************************/
private class Indicator implements HealthIndicator
{
private final String name;
private final QSession session;
/*******************************************************************************
** Select a check.
*******************************************************************************/
private Indicator(String name, QSession session)
{
this.name = name;
this.session = session;
}
@Override
public String getName()
{
return (name);
}
@Override
public HealthCheckResult check(QInstance instance)
{
CapturedContext original = QContext.capture();
try
{
QContext.init(instance, session);
return (switch(name)
{
case "crm.email" -> email();
case "crm.sequenceLag" -> sequenceLag();
default -> leadScore();
});
}
catch(QException | RuntimeException e)
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed"));
}
finally
{
QContext.init(original);
}
}
}
/*******************************************************************************
** An unconfigured transport is degraded, not a claim that email can be sent.
*******************************************************************************/
private HealthCheckResult email() throws QException
{
return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP));
}
/*******************************************************************************
** Query just the oldest active due enrollment.
*******************************************************************************/
private HealthCheckResult sequenceLag() throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId()))
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN, Instant.now().minus(30, ChronoUnit.MINUTES)))
.withOrderBy(new QFilterOrderBy("nextStepDate")).withLimit(1))).getRecords();
return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED));
}
/*******************************************************************************
** No successful score pass, or one more than three hours old, needs attention.
*******************************************************************************/
private HealthCheckResult leadScore() throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME).withFilter(new QQueryFilter(
new QFilterCriteria("name", QCriteriaOperator.EQUALS, CalculateLeadScoresProcess.NAME)).withLimit(1))).getRecords();
Instant last = rows.isEmpty() ? null : rows.getFirst().getValueInstant("lastSuccess");
return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
? HealthStatus.DEGRADED : HealthStatus.UP));
}
}
@@ -0,0 +1,135 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.help;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)
** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks
** process/PVS help slots: action content is applied to frontend steps instead;
** enum entries remain in the bundle for the central loader.
*******************************************************************************/
public final class CrmHelpBundleStandIn
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmHelpBundleStandIn()
{
}
/*******************************************************************************
** Validate the resource eagerly so malformed shipped help fails production.
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml"))
{
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1
|| !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("Invalid CRM bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120
|| entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("Invalid or duplicate help entry: " + key));
}
}
return (entries);
}
catch(IOException | IllegalArgumentException e)
{
throw (new QException("Could not load help/qbit-crm.yaml", e));
}
}
/*******************************************************************************
** Apply help to the objects produced by this QBit only.
*******************************************************************************/
public static void apply(MetaDataProducerMultiOutput output) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : output.getEach(QTableMetaData.class))
{
table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help"));
for(var field : table.getFields().values())
{
String key = "table:" + table.getName() + ";field:" + field.getName();
if(entries.containsKey(key))
{
field.withHelpContent(content(entries, key));
}
}
}
for(QProcessMetaData process : output.getEach(QProcessMetaData.class))
{
QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help");
for(var step : process.getStepList())
{
if(step instanceof QFrontendStepMetaData frontend)
{
frontend.withHelpContent(help.clone());
}
}
}
for(QAppMetaData app : output.getEach(QAppMetaData.class))
{
app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help"));
}
}
/*******************************************************************************
** RC.6 has no summary property, so retain the summary in the bundle only.
*******************************************************************************/
private static QHelpContent content(Map<String, JsonNode> entries, String key) throws QException
{
if(!entries.containsKey(key))
{
throw (new QException("Missing CRM help entry: " + key));
}
return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText()));
}
}
@@ -0,0 +1,247 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.privacy;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Erases the CRM's copies of a contact's personal data using tenant-scoped actions.
*******************************************************************************/
public final class CrmPersonalData
{
/*******************************************************************************
** Narrow consent exception: only replacing evidence text with "erased".
*******************************************************************************/
public enum ErasureFlag implements ActionFlag
{
REDACT_CONSENT
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmPersonalData()
{
}
/*******************************************************************************
** Erase linked data before the contact so retries still have the original email.
*******************************************************************************/
public static void erase(Integer contactId) throws QException
{
QRecord contact = GetAction.execute("crm_contact", contactId);
if(contact == null)
{
throw (new QException("Contact not found: " + contactId));
}
List<Serializable> emails = new ArrayList<>();
for(String field : List.of("email", "secondaryEmail"))
{
if(contact.getValueString(field) != null)
{
emails.add(contact.getValueString(field));
}
}
// Two queries avoid OR filters crossing tenant scopes and make retries idempotent.
eraseForms(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)));
if(!emails.isEmpty())
{
eraseForms(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.IN, emails)));
}
for(QRecord row : linked("crm_email_list_member", contactId))
{
redact("crm_email_list_member", row, "email", "firstName", "confirmationTokenHash", "consentText");
}
for(QRecord row : linked("crm_email_message", contactId))
{
redact("crm_email_message", row, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText");
}
for(QRecord row : linked("crm_activity", contactId))
{
redact("crm_activity", row, "description", "subject");
for(QRecord message : query("crm_email_message", new QQueryFilter(new QFilterCriteria("activityId", QCriteriaOperator.EQUALS, row.getValue("id")))))
{
redact("crm_email_message", message, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText");
}
}
for(QRecord row : linked("crm_consent_record", contactId))
{
checkedUpdate("crm_consent_record", new QRecord().withValue("id", row.getValue("id")).withValue("consentText", "erased"));
redactAudit("crm_consent_record", row.getValue("id"));
}
for(QRecord row : linked("crm_sequence_enrollment", contactId))
{
checkedUpdate("crm_sequence_enrollment", new QRecord().withValue("id", row.getValue("id"))
.withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()).withValue("nextStepDate", null));
redactAudit("crm_sequence_enrollment", row.getValue("id"));
}
for(QRecord row : query("crm_external_mapping", new QQueryFilter(
new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getId()),
new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId))))
{
var deleted = new DeleteAction().execute(new DeleteInput("crm_external_mapping").withPrimaryKey(row.getValue("id")).withOmitDmlAudit(true));
if(deleted.getDeletedRecordCount() != 1)
{
throw (new QException("CRM erase could not delete an external mapping"));
}
redactAudit("crm_external_mapping", row.getValue("id"));
}
redact("crm_contact", contact, "firstName", "lastName", "phone", "mobilePhone", "secondaryEmail", "addressLine1", "addressLine2",
"city", "state", "postalCode", "country", "timezone", "jobTitle", "department", "website", "linkedinUrl", "description");
checkedUpdate("crm_contact", new QRecord().withValue("id", contactId).withValue("email", "anonymized-" + contactId + "@removed.invalid")
.withValue("doNotEmail", true).withValue("doNotCall", true));
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
.withValue("consentType", CrmConsentType.DATA_PROCESSING.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
.withValue("consentText", "Erased on " + LocalDate.now(ZoneOffset.UTC)).withValue("source", "GDPR erasure").withValue("consentDate", Instant.now()));
}
/*******************************************************************************
** Erase both direct and email-matched web leads.
*******************************************************************************/
private static void eraseForms(QQueryFilter filter) throws QException
{
for(QRecord row : query("crm_form_submission", filter))
{
redact("crm_form_submission", row, "firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company");
}
}
/*******************************************************************************
** Collect rows through core record locks, never through a backend bypass.
*******************************************************************************/
private static List<QRecord> linked(String table, Integer contactId) throws QException
{
return (query(table, new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))));
}
/*******************************************************************************
** Page queries to avoid the backend's default result limit silently omitting PII.
*******************************************************************************/
private static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
List<QRecord> result = new ArrayList<>();
Serializable after = null;
while(true)
{
QQueryFilter page = filter.clone().withOrderBy(new QFilterOrderBy("id")).withLimit(250);
if(after != null)
{
page.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after));
}
List<QRecord> rows = new QueryAction().execute(new QueryInput(table).withFilter(page)).getRecords();
result.addAll(rows);
if(rows.size() < 250)
{
return (result);
}
after = rows.getLast().getValue("id");
}
}
/*******************************************************************************
** Keep required fields nonempty; never audit the original values during erasure.
*******************************************************************************/
private static void redact(String table, QRecord row, String... fields) throws QException
{
QRecord update = new QRecord().withValue("id", row.getValue("id"));
for(String field : fields)
{
boolean required = Boolean.TRUE.equals(QContext.getQInstance().getTable(table).getField(field).getIsRequired());
update.withValue(field, required || field.equals("firstName") || field.equals("lastName") ? "[ANONYMIZED]" : null);
}
checkedUpdate(table, update);
redactAudit(table, row.getValue("id"));
}
/*******************************************************************************
** A failed write must fail erasure; never report a partially erased contact as done.
*******************************************************************************/
private static void checkedUpdate(String table, QRecord update) throws QException
{
var output = new UpdateAction().execute(new UpdateInput(table).withRecord(update).withOmitDmlAudit(true).withFlag(ErasureFlag.REDACT_CONSENT));
if(output.getRecords().isEmpty() || output.getRecords().stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
{
throw (new QException("CRM erase could not redact " + table));
}
}
/*******************************************************************************
** RC.6 core audits resolve table ids through auditTable. Redact both values and
** human-readable messages, which can embed old record labels and personal values.
*******************************************************************************/
private static void redactAudit(String table, Serializable recordId) throws QException
{
if(QContext.getQInstance().getTable("auditTable") == null || QContext.getQInstance().getTable("audit") == null
|| QContext.getQInstance().getTable("auditDetail") == null)
{
return;
}
for(QRecord auditTable : query("auditTable", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, table))))
{
for(QRecord audit : query("audit", new QQueryFilter(
new QFilterCriteria("auditTableId", QCriteriaOperator.EQUALS, auditTable.getValue("id")),
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, recordId))))
{
for(QRecord detail : query("auditDetail", new QQueryFilter(new QFilterCriteria("auditId", QCriteriaOperator.EQUALS, audit.getValue("id")))))
{
checkedUpdate("auditDetail", new QRecord().withValue("id", detail.getValue("id"))
.withValue("oldValue", "erased").withValue("newValue", "erased").withValue("message", "erased"));
}
checkedUpdate("audit", new QRecord().withValue("id", audit.getValue("id")).withValue("message", "Personal data erased"));
}
}
}
}
@@ -0,0 +1,65 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.scoring;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Apply assignment rules to new contacts and deals without replacing explicit owners.
*******************************************************************************/
public class AutoAssignCustomizer extends AbstractPostInsertCustomizer
{
@Override
public List<QRecord> apply(List<QRecord> records) throws QException
{
for(QRecord row : records)
{
if(row.getErrors() != null && !row.getErrors().isEmpty())
{
continue;
}
String owner = row.getValueString("ownerUserId");
if(owner == null || "system".equals(owner))
{
CrmAutoAssign.Result result = CrmAutoAssign.assign("crm_contact".equals(insertInput.getTableName())
? CrmAssignableEntity.CONTACT : CrmAssignableEntity.DEAL, row.getValueInteger("id"));
if("ASSIGNED".equals(result.action()))
{
row.setValue("ownerUserId", result.assignedUserId());
QAlerts.notify(new NotificationSignal("CRM lead assigned").withSourceName("crm.lead.assigned")
.withSourceKey(insertInput.getTableName() + ":" + row.getValue("id"))
.withSourceTableName(insertInput.getTableName()).withSourceRecordId(row.getValueString("id"))
.withAudience(NotificationAudience.user(result.assignedUserId())), insertInput.getTransaction());
}
}
}
return (records);
}
}
@@ -0,0 +1,49 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.scoring;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Small persistent maintenance checkpoints, including the last full score pass.
*******************************************************************************/
public class JobState implements MetaDataProducerInterface<QTableMetaData>
{
public static final String TABLE_NAME = "crm_job_state";
@Override
public QTableMetaData produce(QInstance instance)
{
return (new QTableMetaData().withName(TABLE_NAME).withLabel("Job State").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true).withMaxLength(250))
.withField(new QFieldMetaData("lastSuccess", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("lastFullPass", QFieldType.DATE_TIME))
.withUniqueKey(new UniqueKey("name")));
}
}
@@ -26,22 +26,29 @@
package com.kingsrook.qbits.crm.scoring.processes;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qbits.crm.scoring.model.LeadScoreRule;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -83,7 +90,6 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
.withIsHidden(true)
.withLabel("Calculate Lead Scores")
.withIcon(new QIcon().withName("score"))
// Scheduling is configured by the host application via ScheduledJob records
.withStepList(List.of(
new QBackendStepMetaData()
.withName("execute")
@@ -99,6 +105,14 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
Instant startedAt = Instant.now();
List<QRecord> states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords();
QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst();
Instant lastSuccess = state.getValueInstant("lastSuccess");
Instant lastFullPass = state.getValueInstant("lastFullPass");
boolean full = Boolean.TRUE.equals(input.getValueBoolean("fullRecalc")) || lastSuccess == null || lastFullPass == null
|| !lastFullPass.isAfter(startedAt.minus(24, ChronoUnit.HOURS));
///////////////////////////////////////////////
// load all active rules ordered by sort //
///////////////////////////////////////////////
@@ -131,6 +145,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
}
if(!full)
{
filter.withCriteria(new QFilterCriteria("modifyDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, lastSuccess));
}
QueryOutput contactsQuery = new QueryAction().execute(
new QueryInput(Contact.TABLE_NAME).withFilter(filter));
@@ -139,15 +157,20 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
break;
}
Map<Integer, QRecord> companies = loadCompanies(contactsQuery.getRecords());
List<QRecord> changes = new ArrayList<>();
for(QRecord contactRecord : contactsQuery.getRecords())
{
totalContacts++;
if(recalculate(contactRecord, rules))
QRecord change = calculateChange(contactRecord, rules, companies);
if(change != null)
{
updatedCount++;
changes.add(change);
}
lastId = contactRecord.getValueInteger("id");
}
writeScores(changes);
updatedCount += changes.size();
/////////////////////////////////////////////
// if page was not full, we are done //
@@ -158,6 +181,21 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
}
}
CrmDealNotifications.checkRotting();
state.withValue("lastSuccess", startedAt);
if(full)
{
state.withValue("lastFullPass", startedAt);
}
if(states.isEmpty())
{
CrmRecords.insert(JobState.TABLE_NAME, state);
}
else
{
CrmRecords.update(JobState.TABLE_NAME, state);
}
output.addValue("fullRecalc", full);
output.addValue("updatedCount", updatedCount);
output.addValue("totalContacts", totalContacts);
output.addValue("totalRules", rules.size());
@@ -173,18 +211,19 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
List<LeadScoreRule> rules = new QueryAction().execute(new QueryInput(LeadScoreRule.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
.withOrderBy(new QFilterOrderBy("sortOrder")))).getRecords().stream().map(LeadScoreRule::new).toList();
recalculate(contact, rules);
QRecord change = calculateChange(contact, rules, loadCompanies(List.of(contact)));
writeScores(change == null ? List.of() : List.of(change));
}
/*******************************************************************************
** Write only changed scores; return whether a write was needed.
** Compute a score update only when the result changed.
*******************************************************************************/
private boolean recalculate(QRecord contact, List<LeadScoreRule> rules) throws QException
private QRecord calculateChange(QRecord contact, List<LeadScoreRule> rules, Map<Integer, QRecord> companies) throws QException
{
int score = 0;
for(LeadScoreRule rule : rules)
{
if(evaluateRule(rule, contact))
if(evaluateRule(rule, contact, companies))
{
score += rule.getScoreAdjustment();
}
@@ -192,11 +231,27 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
Integer current = contact.getValueInteger("leadScore");
if(Objects.equals(current == null ? 0 : current, score))
{
return (false);
return (null);
}
return (new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score));
}
/*******************************************************************************
** Batch score writes so core audit label resolution also operates per page.
*******************************************************************************/
private void writeScores(List<QRecord> changes) throws QException
{
if(changes.isEmpty())
{
return;
}
UpdateInput update = new UpdateInput(Contact.TABLE_NAME).withOmitModifyDateUpdate(true);
update.setRecords(changes);
var updated = new UpdateAction().execute(update).getRecords();
if(updated.isEmpty() || updated.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
{
throw (new QException("Lead score update was rejected"));
}
new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME)
.withRecord(new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score)));
return (true);
}
@@ -204,7 +259,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
** Evaluate a single rule against a contact record. Supports dot-notation
** field paths for related entities (e.g., "company.industry").
***************************************************************************/
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord)
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord, Map<Integer, QRecord> companies)
{
CrmScoreOperator operator = CrmScoreOperator.getById(rule.getOperator());
if(operator == null)
@@ -213,7 +268,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
}
String fieldPath = rule.getFieldPath();
String actualValue = resolveFieldValue(fieldPath, contactRecord);
String actualValue = resolveFieldValue(fieldPath, contactRecord, companies);
String expectedValue = rule.getFieldValue();
switch(operator)
@@ -233,6 +288,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
case LT:
return (compareNumeric(actualValue, expectedValue) < 0);
case DAYS_AGO_GT:
case DAYS_AGO_LT:
return (compareAge(actualValue, expectedValue, operator));
case IS_SET:
return (StringUtils.hasContent(actualValue));
@@ -272,48 +331,54 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
** for related entity fields (e.g., "company.industry" loads the contact's
** company record and returns its "industry" field).
***************************************************************************/
private String resolveFieldValue(String fieldPath, QRecord contactRecord)
private String resolveFieldValue(String fieldPath, QRecord contactRecord, Map<Integer, QRecord> companies)
{
if(fieldPath == null || !fieldPath.contains("."))
{
return (contactRecord.getValueString(fieldPath));
}
String[] parts = fieldPath.split("\\.", 2);
String prefix = parts[0];
String fieldName = parts[1];
try
if("company".equalsIgnoreCase(parts[0]))
{
if("company".equalsIgnoreCase(prefix))
{
Integer companyId = contactRecord.getValueInteger("companyId");
if(companyId == null)
{
return (null);
}
GetOutput companyOutput = new GetAction().execute(
new GetInput(Company.TABLE_NAME).withPrimaryKey(companyId));
if(companyOutput.getRecord() == null)
{
return (null);
}
return (companyOutput.getRecord().getValueString(fieldName));
}
else
{
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + prefix);
return (null);
}
}
catch(Exception e)
{
LOG.warn("Error resolving dot-notation field [" + fieldPath + "] for contact", e);
return (null);
QRecord company = companies.get(contactRecord.getValueInteger("companyId"));
return (company == null ? null : company.getValueString(parts[1]));
}
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + parts[0]);
return (null);
}
/*******************************************************************************
** One company query per page, shared by every rule and contact in that page.
*******************************************************************************/
private Map<Integer, QRecord> loadCompanies(List<QRecord> contacts) throws QException
{
List<Integer> ids = contacts.stream().map(row -> row.getValueInteger("companyId")).filter(Objects::nonNull).distinct().toList();
Map<Integer, QRecord> companies = new HashMap<>();
if(!ids.isEmpty())
{
for(QRecord company : new QueryAction().execute(new QueryInput(Company.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)))).getRecords())
{
companies.put(company.getValueInteger("id"), company);
}
}
return (companies);
}
/*******************************************************************************
** Compare instants or UTC calendar dates with the strict N-days-ago boundary.
*******************************************************************************/
private boolean compareAge(String actual, String expected, CrmScoreOperator operator)
{
try
{
Instant date = actual.length() == 10 ? LocalDate.parse(actual).atStartOfDay(ZoneOffset.UTC).toInstant() : Instant.parse(actual);
Instant threshold = Instant.now().minus(Long.parseLong(expected), ChronoUnit.DAYS);
return (operator == CrmScoreOperator.DAYS_AGO_GT ? date.isBefore(threshold) : date.isAfter(threshold));
}
catch(RuntimeException e)
{
return (false);
}
}
}
@@ -0,0 +1,137 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.security;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.CrmAppMetaData;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** CRM field classification and optional tenant record locks.
*******************************************************************************/
public final class CrmDataPolicy
{
private static final Map<String, List<String>> PERSONAL_FIELDS = Map.of(
"crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1",
"addressLine2", "city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"),
"crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"),
"crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"),
"crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"),
"crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"),
"crm_consent_record", List.of("consentText"),
"crm_activity", List.of("description"));
private static final Map<String, String> PARENTS = Map.ofEntries(
Map.entry("crm_pipeline_stage", "crm_pipeline"),
Map.entry("crm_assignment_rule_member", "crm_assignment_rule"),
Map.entry("crm_consent_record", "crm_contact"),
Map.entry("crm_email_list_member", "crm_contact"),
Map.entry("crm_email_message", "crm_contact"),
Map.entry("crm_sequence_enrollment", "crm_contact"),
Map.entry("crm_sequence_step", "crm_email_sequence"),
Map.entry("crm_activity_participant", "crm_activity"),
Map.entry("crm_deal_contact", "crm_deal"),
Map.entry("crm_deal_product", "crm_deal"),
Map.entry("crm_deal_stage_history", "crm_deal"));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmDataPolicy()
{
}
/*******************************************************************************
** Apply after joins have their final names, before backend name inference.
*******************************************************************************/
public static void apply(MetaDataProducerMultiOutput output, QInstance instance, CrmQBitConfig config)
{
String keyType = config.getTenantSecurityKeyType();
if(keyType != null && instance.getSecurityKeyType(keyType) == null)
{
output.add(new QSecurityKeyType().withName(keyType).withAllAccessKeyName(config.getTenantAllAccessKeyName()));
}
for(QTableMetaData table : output.getEach(QTableMetaData.class))
{
for(String name : PERSONAL_FIELDS.getOrDefault(table.getName(), List.of()))
{
QFieldMetaData field = table.getField(name);
if(field == null)
{
throw (new IllegalStateException("Unknown CRM PII field: " + table.getName() + "." + name));
}
AiFieldMetaData ai = AiFieldMetaData.of(field);
field.withSupplementalMetaData((ai == null ? new AiFieldMetaData() : ai).withClassification(DataClassification.PII));
}
if(keyType == null)
{
continue;
}
RecordSecurityLock lock = new RecordSecurityLock().withSecurityKeyType(keyType);
String parent = PARENTS.get(table.getName());
if(parent == null)
{
// Polymorphic external mappings and internal job checkpoints need a direct key too.
table.withField(new QFieldMetaData("tenantKey", QFieldType.STRING).withMaxLength(250));
if(table.getSections() != null && !table.getSections().isEmpty())
{
table.withSection(new QFieldSection("crm_tenancy", "Tenancy", new QIcon("lock"), Tier.T3, List.of("tenantKey")));
}
lock.withFieldName("tenantKey");
}
else
{
QJoinMetaData join = output.getEach(QJoinMetaData.class).stream()
.filter(candidate -> parent.equals(candidate.getLeftTable()) && table.getName().equals(candidate.getRightTable()))
.findFirst().orElseThrow(() -> new IllegalStateException("Missing tenant parent join for " + table.getName()));
lock.withFieldName(parent + ".tenantKey").withJoinNameChain(List.of(join.getName()));
}
table.withRecordSecurityLock(lock);
}
}
/*******************************************************************************
** Main and settings tables exposed through the host API.
*******************************************************************************/
public static boolean isPublishedTable(String name)
{
return (CrmAppMetaData.MAIN_TABLES.contains(name) || CrmAppMetaData.SETTINGS_TABLES.contains(name));
}
}
@@ -49,7 +49,7 @@ public final class CrmPermissions
public static final List<String> ALL = List.of(APP_ACCESS, READ, WRITE, MANAGE, MANAGE_READ, MANAGE_WRITE, ADMIN, ADMIN_READ, ADMIN_WRITE);
private static final Set<String> SETTINGS_TABLES = Set.of(
"crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
"crm_job_state", "crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence",
"crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_lifecycle_stage",
"crm_lead_source", "crm_industry", "crm_activity_type", "crm_activity_outcome",
"crm_contact_role", "crm_win_loss_reason");
@@ -58,7 +58,7 @@ public final class CrmPermissions
"crm_merge_contacts", "crm_merge_companies", "crm_bulk_assign_owner",
"crm_subscribe_to_list", "crm_unsubscribe_from_list", "crm_enroll_in_sequence",
"crm_unenroll_from_sequence", "crm_process_sequence_steps", "crm_assign_company_owner",
"crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence");
"crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence", "crm_sync_email_delivery", "crm_expire_consents", "crm_calculate_lead_scores");
@@ -0,0 +1,86 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.security;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** CRM relationship invariants, enforced for inserts and partial updates.
*******************************************************************************/
public class CrmRelationshipValidator implements TableCustomizerInterface
{
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview,
Optional<List<QRecord>> oldRecords) throws QException
{
Map<Object, QRecord> oldById = new HashMap<>();
oldRecords.orElse(List.of()).forEach(row -> oldById.put(row.getValue("id"), row));
for(QRecord record : records)
{
QRecord effective = new QRecord();
QRecord old = oldById.get(record.getValue("id"));
if(old != null)
{
effective.getValues().putAll(old.getValues());
}
effective.getValues().putAll(record.getValues());
if(effective.getValues().containsKey("pipelineStageId"))
{
validate(record, effective, "pipelineStageId", "pipelineId", "crm_pipeline_stage");
}
if(effective.getValues().containsKey("outcomeId"))
{
validate(record, effective, "outcomeId", "activityTypeId", "crm_activity_outcome");
}
}
return (records);
}
/*******************************************************************************
** Missing optional choices are allowed; populated choices must belong to the parent.
*******************************************************************************/
private void validate(QRecord record, QRecord effective, String childField, String parentField, String source) throws QException
{
if(effective.getValue(childField) == null)
{
return;
}
QRecord choice = GetAction.execute(source, effective.getValue(childField));
if(choice == null || effective.getValue(parentField) == null
|| !Objects.equals(choice.getValueInteger(parentField), effective.getValueInteger(parentField)))
{
record.addError(new BadInputStatusMessage("Choose a value belonging to the selected " + parentField + "."));
}
}
}
@@ -22,19 +22,26 @@
package com.kingsrook.qbits.crm.widgets;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
@@ -70,9 +77,41 @@ public class CrmContactTimelineRenderer extends AbstractWidgetRenderer
String kind = Map.of("crm_activity", "Activity", "crm_email_message", "Email message",
"crm_form_submission", "Web lead", "crm_consent_record", "Consent record").get(table);
String detail = Objects.toString(record.getValue("subject"), Objects.toString(record.getValue("email"), kind));
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), record.getValue("createDate"), kind, detail));
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), formatDate(record.getValueInstant("createDate")), kind, detail));
}
}
return (new RenderWidgetOutput(CrmHomeRenderer.table(rows, "Recorded", "Kind", "Details")));
}
/*******************************************************************************
** Prefer the session timezone (including DST), then offset, otherwise UTC.
*******************************************************************************/
static String formatDate(Instant instant)
{
if(instant == null)
{
return ("");
}
ZoneId zone = ZoneOffset.UTC;
QSession session = QContext.getQSession();
String timezone = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE);
String offset = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES);
try
{
if(timezone != null && !timezone.isBlank())
{
zone = ZoneId.of(timezone);
}
else if(offset != null && !offset.isBlank())
{
zone = ZoneOffset.ofTotalSeconds(Math.multiplyExact(60, Integer.parseInt(offset)));
}
}
catch(DateTimeException | IllegalArgumentException | ArithmeticException e)
{
zone = ZoneOffset.UTC;
}
return (DateTimeFormatter.ofPattern("MMM d, uuuu h:mm a", Locale.US).withZone(zone).format(instant));
}
}
@@ -25,6 +25,8 @@ package com.kingsrook.qbits.crm.widgets;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
@@ -33,6 +35,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
@@ -118,11 +121,20 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Preserve decimals and large counts in the standard statistics renderer.
** Format statistics with the configured base currency in the value.
*******************************************************************************/
private StatisticsData statistic(BigDecimal value, boolean currency)
private StatisticsData statistic(BigDecimal value, boolean currency) throws QException
{
return (new StatisticsData().withCount(value).withCountContext(currency ? "base currency" : null));
String prefix = "";
if(currency)
{
List<QRecord> base = query("crm_currency", equalsFilter("isBaseCurrency", true), "id", true, 1);
if(!base.isEmpty() && base.getFirst().getValueString("currencyCode") != null)
{
prefix = base.getFirst().getValueString("currencyCode") + " ";
}
}
return (new StatisticsData().withCount(prefix + formatAmount(value)));
}
@@ -197,7 +209,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Stage and deal count share one column, keeping both monetary columns.
** Separate the stage label, integer count and weighted base-currency amount.
*******************************************************************************/
private TableData pipeline(RenderWidgetInput input) throws QException
{
@@ -220,10 +232,10 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
{
BigDecimal[] values = totals.getOrDefault(stage.getValue("id"), new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
String href = AiLinkBuilder.recordHref(STAGE, stage.getValue("id"));
rows.add(row(href, stage.getValueString("name") + " (" + values[0].toPlainString() + " deals)", values[1], values[2]));
rows.add(row(href, stage.getValueString("name"), values[0].toBigIntegerExact(), formatAmount(values[2])));
}
}
TableData data = table(rows, "Stage / deals", "Amount (base)", "Weighted amount (base)");
TableData data = table(rows, "Stage", "Deals", "Weighted amount");
if(pipelines.size() > 1)
{
data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline"))
@@ -246,7 +258,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
LocalDate start = today.withDayOfMonth(1).plusMonths(month);
QQueryFilter filter = period(dealStatus(false), "expectedCloseDate", start, start.plusMonths(1));
BigDecimal[] totals = totals(filter);
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0], totals[2]));
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0].toBigIntegerExact(), formatAmount(totals[2])));
}
return (table(rows, "Month", "Deals", "Weighted amount (base)"));
}
@@ -292,7 +304,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
for(QRecord lead : query("crm_form_submission", new QQueryFilter(), "createDate", false, 10))
{
String name = (Objects.toString(lead.getValue("firstName"), "") + " " + Objects.toString(lead.getValue("lastName"), "")).trim();
if(name.isBlank())
if(Objects.toString(lead.getValue("firstName"), "").isBlank() || Objects.toString(lead.getValue("lastName"), "").isBlank())
{
name = Objects.toString(lead.getValue("email"), "Web lead");
}
@@ -339,7 +351,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
BigDecimal target = CrmCurrencyUtils.convertToBaseCurrency(goal.getValueBigDecimal("targetAmount"), goal.getValueString("currencyCode"));
String attainment = target != null && target.signum() > 0 ? actual.multiply(BigDecimal.valueOf(100)).divide(target, 1, RoundingMode.HALF_UP) + "%" : "No target";
rows.add(row(AiLinkBuilder.recordHref("crm_sales_goal", goal.getValue("id")),
goal.getValueString("userId") + " (" + attainment + ")", target, actual));
goal.getValueString("userId") + " (" + attainment + ")", formatAmount(target), formatAmount(actual)));
}
TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)");
return (data);
@@ -362,7 +374,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
Serializable user = result.getGroupByValue(owner);
QQueryFilter ownerFilter = filter.clone().withCriteria(new QFilterCriteria("ownerUserId", user == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, user));
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, ownerFilter), Objects.toString(user, "Unassigned"),
number(result.getAggregateValue(COUNT)), number(result.getAggregateValue(AMOUNT))));
number(result.getAggregateValue(COUNT)).toBigIntegerExact(), formatAmount(number(result.getAggregateValue(AMOUNT)))));
}
return (table(rows, "Owner", "Won deals", "Won amount (base)"));
}
@@ -457,6 +469,18 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
}
/*******************************************************************************
** Group amounts without losing fractional precision or padding whole amounts.
*******************************************************************************/
static String formatAmount(BigDecimal value)
{
BigDecimal amount = value == null ? BigDecimal.ZERO : value;
DecimalFormat format = new DecimalFormat("#,##0", DecimalFormatSymbols.getInstance(Locale.US));
format.setMaximumFractionDigits(Math.max(0, amount.scale()));
return (format.format(amount));
}
/*******************************************************************************
** Every cell navigates to its record, or to the records in an aggregate.
*******************************************************************************/
@@ -489,8 +513,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));
}
}
@@ -54,7 +54,7 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
{
output.add(widget(HOME.get(i), labels.get(i), i == 0 ? WidgetType.PARENT_WIDGET : WidgetType.TABLE));
}
List<String> statistics = List.of("Open pipeline (base currency)", "Won this month (base currency)", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
List<String> statistics = List.of("Open pipeline", "Won this month", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
for(int i = 0; i < TODAY.size(); i++)
{
output.add(widget(TODAY.get(i), statistics.get(i), WidgetType.STATISTICS));
@@ -0,0 +1,128 @@
[
{
"label": "CRM Contacts - Generic CSV",
"tableName": "crm_contact",
"mapping": {
"version": "1",
"layout": "FLAT",
"hasHeaderRow": true,
"isBulkEdit": false,
"fieldList": [
{
"fieldName": "firstName",
"headerName": "First Name",
"columnIndex": 0
},
{
"fieldName": "lastName",
"headerName": "Last Name",
"columnIndex": 1
},
{
"fieldName": "email",
"headerName": "Email",
"columnIndex": 2
},
{
"fieldName": "phone",
"headerName": "Phone",
"columnIndex": 3
},
{
"fieldName": "companyId",
"headerName": "Company",
"columnIndex": 4
},
{
"fieldName": "jobTitle",
"headerName": "Job Title",
"columnIndex": 5
}
]
}
},
{
"label": "CRM Companies - Generic CSV",
"tableName": "crm_company",
"mapping": {
"version": "1",
"layout": "FLAT",
"hasHeaderRow": true,
"isBulkEdit": false,
"fieldList": [
{
"fieldName": "name",
"headerName": "Company",
"columnIndex": 0
},
{
"fieldName": "domain",
"headerName": "Domain",
"columnIndex": 1
},
{
"fieldName": "phone",
"headerName": "Phone",
"columnIndex": 2
},
{
"fieldName": "addressLine1",
"headerName": "Address",
"columnIndex": 3
},
{
"fieldName": "city",
"headerName": "City",
"columnIndex": 4
},
{
"fieldName": "postalCode",
"headerName": "Postal Code",
"columnIndex": 5
}
]
}
},
{
"label": "CRM Deals - Generic CSV",
"tableName": "crm_deal",
"mapping": {
"version": "1",
"layout": "FLAT",
"hasHeaderRow": true,
"isBulkEdit": false,
"fieldList": [
{
"fieldName": "name",
"headerName": "Deal",
"columnIndex": 0
},
{
"fieldName": "companyId",
"headerName": "Company",
"columnIndex": 1
},
{
"fieldName": "pipelineId",
"headerName": "Pipeline",
"columnIndex": 2
},
{
"fieldName": "pipelineStageId",
"headerName": "Stage",
"columnIndex": 3
},
{
"fieldName": "amount",
"headerName": "Amount",
"columnIndex": 4
},
{
"fieldName": "currencyCode",
"headerName": "Currency",
"columnIndex": 5
}
]
}
}
]
@@ -0,0 +1,50 @@
--liquibase formatted sql
-- Optional: include after crm.changelog.sql ONLY for tenant-enabled installations.
-- Run with context crm-tenancy. Omit this file entirely when tenancy is off.
--changeset qbit-crm:020-crm-tenancy context:@crm-tenancy
ALTER TABLE crm_contact ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_contact_tenant ON crm_contact (tenant_key);
ALTER TABLE crm_company ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_company_tenant ON crm_company (tenant_key);
ALTER TABLE crm_deal ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_deal_tenant ON crm_deal (tenant_key);
ALTER TABLE crm_activity ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_activity_tenant ON crm_activity (tenant_key);
ALTER TABLE crm_form_submission ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_form_submission_tenant ON crm_form_submission (tenant_key);
ALTER TABLE crm_email_list ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_email_list_tenant ON crm_email_list (tenant_key);
ALTER TABLE crm_pipeline ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_pipeline_tenant ON crm_pipeline (tenant_key);
ALTER TABLE crm_product ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_product_tenant ON crm_product (tenant_key);
ALTER TABLE crm_email_template ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_email_template_tenant ON crm_email_template (tenant_key);
ALTER TABLE crm_email_sequence ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_email_sequence_tenant ON crm_email_sequence (tenant_key);
ALTER TABLE crm_external_mapping ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_external_mapping_tenant ON crm_external_mapping (tenant_key);
ALTER TABLE crm_lead_score_rule ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_lead_score_rule_tenant ON crm_lead_score_rule (tenant_key);
ALTER TABLE crm_assignment_rule ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_assignment_rule_tenant ON crm_assignment_rule (tenant_key);
ALTER TABLE crm_sales_goal ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_sales_goal_tenant ON crm_sales_goal (tenant_key);
ALTER TABLE crm_currency ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_currency_tenant ON crm_currency (tenant_key);
ALTER TABLE crm_lifecycle_stage ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_lifecycle_stage_tenant ON crm_lifecycle_stage (tenant_key);
ALTER TABLE crm_lead_source ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_lead_source_tenant ON crm_lead_source (tenant_key);
ALTER TABLE crm_industry ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_industry_tenant ON crm_industry (tenant_key);
ALTER TABLE crm_activity_type ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_activity_type_tenant ON crm_activity_type (tenant_key);
ALTER TABLE crm_activity_outcome ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_activity_outcome_tenant ON crm_activity_outcome (tenant_key);
ALTER TABLE crm_contact_role ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_contact_role_tenant ON crm_contact_role (tenant_key);
ALTER TABLE crm_win_loss_reason ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_win_loss_reason_tenant ON crm_win_loss_reason (tenant_key);
ALTER TABLE crm_job_state ADD COLUMN tenant_key VARCHAR(250);
CREATE INDEX idx_crm_job_state_tenant ON crm_job_state (tenant_key);
@@ -1,12 +1,13 @@
--liquibase formatted sql
-- Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0.
-- https://www.apache.org/licenses/LICENSE-2.0
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (33 tables).
-- QBit CRM 0.2.0 P1 PostgreSQL reference schema (34 tables).
-- QQQ table names equal SQL names; Java field names map to snake_case columns.
-- Unique constraints match QQQ metadata. Relationship columns are indexed;
-- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns
-- validation and deletion policy, including polymorphic and optional references).
-- Optional host-configured securityFields require matching host-managed columns.
-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy.
-- With tenancy off, do not include it; this base schema has no tenant_key columns.
--changeset qbit-crm:020-crm_activity
CREATE TABLE IF NOT EXISTS crm_activity (
@@ -171,27 +172,27 @@ CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_recor
--changeset qbit-crm:020-crm_contact
CREATE TABLE IF NOT EXISTS crm_contact (
first_touch_utm_source VARCHAR(250),
first_touch_utm_medium VARCHAR(250),
first_touch_utm_campaign VARCHAR(250),
first_touch_utm_content VARCHAR(250),
first_touch_utm_term VARCHAR(250),
first_touch_utm_source VARCHAR(500),
first_touch_utm_medium VARCHAR(500),
first_touch_utm_campaign VARCHAR(500),
first_touch_utm_content VARCHAR(500),
first_touch_utm_term VARCHAR(500),
first_touch_landing_page VARCHAR(1000),
first_touch_referrer VARCHAR(1000),
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
last_touch_utm_source VARCHAR(250),
last_touch_utm_medium VARCHAR(250),
last_touch_utm_campaign VARCHAR(250),
last_touch_utm_content VARCHAR(250),
last_touch_utm_term VARCHAR(250),
last_touch_utm_source VARCHAR(500),
last_touch_utm_medium VARCHAR(500),
last_touch_utm_campaign VARCHAR(500),
last_touch_utm_content VARCHAR(500),
last_touch_utm_term VARCHAR(500),
last_touch_landing_page VARCHAR(1000),
last_touch_referrer VARCHAR(1000),
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(250),
secondary_email VARCHAR(250),
email VARCHAR(320),
secondary_email VARCHAR(320),
phone VARCHAR(50),
mobile_phone VARCHAR(50),
job_title VARCHAR(150),
@@ -199,7 +200,7 @@ CREATE TABLE IF NOT EXISTS crm_contact (
company_id INTEGER,
lifecycle_stage_id INTEGER,
lead_source_id INTEGER,
owner_user_id TEXT NOT NULL,
owner_user_id TEXT,
address_line1 VARCHAR(250),
address_line2 VARCHAR(250),
city VARCHAR(100),
@@ -265,7 +266,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
weighted_amount DECIMAL(20,6),
company_id INTEGER,
primary_contact_id INTEGER,
owner_user_id TEXT NOT NULL,
owner_user_id TEXT,
lead_source_id INTEGER,
expected_close_date DATE,
actual_close_date DATE,
@@ -364,7 +365,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email_list_id INTEGER NOT NULL,
contact_id INTEGER,
email VARCHAR(250) NOT NULL,
email VARCHAR(320) NOT NULL,
first_name VARCHAR(100),
status INTEGER DEFAULT 1 NOT NULL,
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
@@ -383,9 +384,13 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
--changeset qbit-crm:020-crm_email_message
CREATE TABLE IF NOT EXISTS crm_email_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
idempotency_key VARCHAR(250) UNIQUE,
email_message_id VARCHAR(100),
delivered_date TIMESTAMP WITHOUT TIME ZONE,
bounced_date TIMESTAMP WITHOUT TIME ZONE,
activity_id INTEGER,
contact_id INTEGER,
from_address VARCHAR(250) NOT NULL,
from_address VARCHAR(250),
from_name VARCHAR(100),
to_addresses TEXT NOT NULL,
cc_addresses TEXT,
@@ -411,6 +416,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
modify_date TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_message_id ON crm_email_message (email_message_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
@@ -478,18 +484,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
client_hash VARCHAR(64),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
form_type INTEGER NOT NULL,
email VARCHAR(250) NOT NULL,
email VARCHAR(320) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
company VARCHAR(250),
phone VARCHAR(50),
message TEXT,
source_url VARCHAR(1000),
utm_source VARCHAR(250),
utm_medium VARCHAR(250),
utm_campaign VARCHAR(250),
utm_term VARCHAR(250),
utm_content VARCHAR(250),
utm_source VARCHAR(500),
utm_medium VARCHAR(500),
utm_campaign VARCHAR(500),
utm_term VARCHAR(500),
utm_content VARCHAR(500),
referrer VARCHAR(1000),
contact_id INTEGER,
conversion_status INTEGER DEFAULT 1 NOT NULL,
@@ -674,3 +680,17 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
);
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
--changeset qbit-crm:crm_job_state
CREATE TABLE IF NOT EXISTS crm_job_state (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(250) NOT NULL UNIQUE,
last_success TIMESTAMP WITHOUT TIME ZONE,
last_full_pass TIMESTAMP WITHOUT TIME ZONE
);
--changeset qbit-crm:crm_nullable_intake_names_and_owners
ALTER TABLE crm_contact ALTER COLUMN first_name DROP NOT NULL;
ALTER TABLE crm_contact ALTER COLUMN last_name DROP NOT NULL;
ALTER TABLE crm_contact ALTER COLUMN owner_user_id DROP NOT NULL;
ALTER TABLE crm_deal ALTER COLUMN owner_user_id DROP NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -35,7 +35,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -70,7 +69,6 @@ class CrmAppMetaDataTest extends BaseTest
assertThat(app.getWidgets()).containsExactlyElementsOf(CrmHomeWidgets.HOME);
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("crm");
assertThat(app.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
assertThat(((SourceQBitAware) app).getSourceQBitName()).isEqualTo("com.kingsrook.qbits:crm");
assertThat(CrmQBitProducer.produceAppSection().getTables()).containsExactlyElementsOf(CrmAppMetaData.MAIN_TABLES);
assertThat(QContext.getQInstance().getTable("crm_form_submission").getLabel()).isEqualTo("Web Leads");
assertThat(QContext.getQInstance().getTable("crm_email_list").getLabel()).isEqualTo("Lists");
@@ -90,7 +88,7 @@ class CrmAppMetaDataTest extends BaseTest
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME).withAppEnabled(enabled))
.produce(instance).addSelfToInstance(instance);
assertThat(instance.getApps()).hasSize(Boolean.FALSE.equals(enabled) ? 0 : 1);
assertThat(instance.getTables()).hasSize(33);
assertThat(instance.getTables()).hasSize(34);
}
}
@@ -139,7 +137,7 @@ class CrmAppMetaDataTest extends BaseTest
/*******************************************************************************
** No retired trends; all widgets have explicit protection and full width.
** No retired trends; all widgets have explicit protection and statistics use compact widths.
*******************************************************************************/
@Test
void widgets()
@@ -149,7 +147,7 @@ class CrmAppMetaDataTest extends BaseTest
for(QWidgetMetaDataInterface widget : instance.getWidgets().values())
{
assertThat(widget.getPermissionRules()).isNotNull();
assertThat(widget.getGridColumns()).isEqualTo(12);
assertThat(widget.getGridColumns()).isEqualTo(CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType());
}
}
@@ -0,0 +1,163 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.regex.Pattern;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** PII inventory and real tenant isolation through core QueryAction.
*******************************************************************************/
class CrmDataPolicyTest extends BaseTest
{
/*******************************************************************************
** Complete pii inventory.
*******************************************************************************/
@Test
void completePiiInventory()
{
Map<String, List<String>> expected = Map.of(
"crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1", "addressLine2",
"city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"),
"crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"),
"crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"),
"crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"),
"crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"),
"crm_consent_record", List.of("consentText"), "crm_activity", List.of("description"));
TreeSet<String> actual = new TreeSet<>();
QContext.getQInstance().getTables().values().forEach(table -> table.getFields().values().forEach(field ->
{
if(AiFieldMetaData.of(field) != null && AiFieldMetaData.of(field).getClassification() == DataClassification.PII)
{
actual.add(table.getName() + "." + field.getName());
}
}));
TreeSet<String> inventory = new TreeSet<>();
expected.forEach((table, fields) -> fields.forEach(field -> inventory.add(table + "." + field)));
assertThat(actual).containsExactlyElementsOf(inventory);
}
/*******************************************************************************
** Personal field names cannot lose classification.
*******************************************************************************/
@Test
void personalFieldNamesCannotLoseClassification()
{
Pattern pattern = Pattern.compile("email|phone|firstName|lastName|address|postal|linkedin", Pattern.CASE_INSENSITIVE);
QContext.getQInstance().getTables().values().forEach(table -> table.getFields().values().forEach(field ->
{
// Foreign-key ids, preference booleans and transport message ids are not personal text.
if(field.getType() == QFieldType.STRING && pattern.matcher(field.getName()).find() && !field.getName().equals("emailMessageId"))
{
assertThat(AiFieldMetaData.of(field)).as(table.getName() + "." + field.getName()).isNotNull();
assertThat(AiFieldMetaData.of(field).getClassification()).isEqualTo(DataClassification.PII);
}
}));
}
/*******************************************************************************
** Tenancy off has no fields or locks.
*******************************************************************************/
@Test
void tenancyOffHasNoFieldsOrLocks()
{
assertThat(new CrmQBitConfig().getTenantSecurityKeyType()).isNull();
QContext.getQInstance().getTables().values().forEach(table ->
{
assertThat(table.getFields().get("tenantKey")).isNull();
assertThat(table.getRecordSecurityLocks()).isNullOrEmpty();
});
}
/*******************************************************************************
** Tenant asees only its contacts deals and joined products.
*******************************************************************************/
@Test
void tenantASeesOnlyItsContactsDealsAndJoinedProducts() throws Exception
{
QInstance instance = P4TestSupport.host();
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
.withTenantSecurityKeyType("tenant").withTenantAllAccessKeyName("allTenants")).produce(instance).addSelfToInstance(instance);
new QInstanceValidator().validate(instance);
QContext.init(instance, new QSession().withPermissions(CrmPermissions.ALL).withSecurityKeyValue("tenant", "A"));
for(String table : List.of("crm_contact", "crm_deal"))
{
P4TestSupport.seed(table, new QRecord().withValue("id", 1).withValue("tenantKey", "A"),
new QRecord().withValue("id", 2).withValue("tenantKey", "B"), new QRecord().withValue("id", 3));
}
P4TestSupport.seed("crm_deal_product", new QRecord().withValue("id", 11).withValue("dealId", 1),
new QRecord().withValue("id", 22).withValue("dealId", 2));
assertThat(ids("crm_contact")).containsExactly(1);
assertThat(ids("crm_deal")).containsExactly(1);
assertThat(ids("crm_deal_product")).containsExactly(11);
QContext.getQSession().withSecurityKeyValue("allTenants", true);
assertThat(ids("crm_contact")).contains(1, 2);
assertThat(ids("crm_deal")).contains(1, 2);
assertThat(ids("crm_deal_product")).containsExactlyInAnyOrder(11, 22);
instance.getTables().values().forEach(table -> assertThat(table.getRecordSecurityLocks()).as(table.getName()).isNotEmpty());
assertThat(instance.getTable("crm_deal_product").getFields().get("tenantKey")).isNull();
}
/*******************************************************************************
** Preserves host key type.
*******************************************************************************/
@Test
void preservesHostKeyType() throws Exception
{
QInstance instance = P4TestSupport.host();
QSecurityKeyType key = new QSecurityKeyType().withName("tenant").withAllAccessKeyName("hostAll");
instance.addSecurityKeyType(key);
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
.withTenantSecurityKeyType("tenant").withTenantAllAccessKeyName("ignored")).produce(instance).addSelfToInstance(instance);
assertThat(instance.getSecurityKeyType("tenant")).isSameAs(key);
}
/*******************************************************************************
** Read through real record locks.
*******************************************************************************/
private List<Integer> ids(String table) throws Exception
{
return (new QueryAction().execute(new QueryInput(table)).getRecords().stream().map(row -> row.getValueInteger("id")).toList());
}
}
@@ -0,0 +1,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,138 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.List;
import com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Presentation contracts remain stable across all discovered CRM metadata.
*******************************************************************************/
class CrmPresentationTest extends BaseTest
{
/*******************************************************************************
** Every table has an explicit human label and user identifiers read naturally.
*******************************************************************************/
@Test
void labelsAndMoney()
{
var instance = QContext.getQInstance();
for(var table : instance.getTables().values())
{
assertThat(table.getLabel()).isNotBlank().doesNotContain("_").isNotEqualToIgnoringCase(table.getName());
table.getFields().values().stream().filter(field -> field.getName().endsWith("UserId"))
.forEach(field -> assertThat(field.getLabel()).doesNotContain("User Id"));
}
assertThat(instance.getTable("crm_contact").getLabel()).isEqualTo("Contacts");
assertThat(instance.getTable("crm_deal").getField("amount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
assertThat(instance.getTable("crm_deal_product").getField("totalAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
assertThat(instance.getTable("crm_product").getField("unitPrice").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
assertThat(instance.getTable("crm_sales_goal").getField("targetAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
for(String table : List.of("crm_contact", "crm_deal"))
{
assertThat(instance.getTable(table).getField("ownerUserId").getIsRequired()).isFalse();
assertThat(instance.getTable(table).getField("ownerUserId").getLabel()).isEqualTo("Owner");
}
}
/*******************************************************************************
** All record-screen child lists have at most four valid, deliberate columns.
*******************************************************************************/
@Test
void sectionsAndChildLists()
{
var instance = QContext.getQInstance();
var contact = instance.getTable("crm_contact");
assertThat(contact.getSections()).extracting(section -> section.getName())
.contains("details", "contactInformation", "attribution", "system").doesNotContain("online", "address", "communication", "data", "dates");
assertThat(contact.getSections().stream().filter(section -> section.getName().equals("system")).findFirst().orElseThrow().getTier()).isEqualTo(Tier.T3);
for(String parent : List.of("crm_contact", "crm_company", "crm_deal"))
{
instance.getTable(parent).getSections().stream().filter(section -> section.getWidgetName() != null).forEach(section ->
{
var widget = instance.getWidget(section.getWidgetName());
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
{
var child = instance.getTable(instance.getJoin(joinName).getRightTable());
List<?> fields = (List<?>) widget.getDefaultValues().get("onlyIncludeFieldNames");
assertThat(fields).isNotEmpty().hasSizeLessThanOrEqualTo(4);
fields.forEach(field -> assertThat(child.getFields()).containsKey(field.toString()));
}
});
}
}
/*******************************************************************************
** A computed label must not re-expose fields omitted by the record view.
*******************************************************************************/
@Test
void hiddenNamesStayOutOfLabels() throws Exception
{
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
.withValue("firstName", "Private").withValue("lastName", "Name").withValue("email", "visible@example.test"))).getRecords().getFirst();
var table = QContext.getQInstance().getTable("crm_contact");
table.getField("firstName").setIsHidden(true);
table.getField("lastName").setIsHidden(true);
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput("crm_contact").withPrimaryKey(inserted.getValue("id"));
input.setShouldOmitHiddenFields(true);
QRecord visible = new GetAction().execute(input).getRecord();
assertThat(visible.getRecordLabel()).isEqualTo("visible@example.test");
assertThat(visible.getValue("firstName")).isNull();
}
/*******************************************************************************
** Missing names remain null while get and query return useful record labels.
*******************************************************************************/
@Test
void contactNameFallbacks() throws Exception
{
for(String[] values : new String[][] {{"Ada", "Lovelace", "Ada Lovelace"}, {"Ada", null, "Ada"}, {null, null, "ada@example.test"}})
{
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
.withValue("firstName", values[0]).withValue("lastName", values[1]).withValue("email", "ada@example.test"))).getRecords().getFirst();
assertThat(inserted.getErrors()).isNullOrEmpty();
QRecord record = GetAction.execute("crm_contact", inserted.getValue("id"));
assertThat(record.getRecordLabel()).isEqualTo(values[2]);
assertThat(record.getValue("lastName")).isEqualTo(values[1]);
}
assertThat(new QueryAction().execute(new QueryInput("crm_contact")).getRecords()).extracting(QRecord::getRecordLabel)
.containsExactly("Ada Lovelace", "Ada", "ada@example.test");
assertThat(ContactLabelCustomizer.label(new QRecord().withValue("firstName", " ").withValue("lastName", " ").withValue("email", "mail@example.test")))
.isEqualTo("mail@example.test");
}
}
@@ -0,0 +1,152 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction;
import com.kingsrook.qqq.api.model.APIVersion;
import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput;
import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData;
import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer;
import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Published fields, search declaration and shipped help coverage.
*******************************************************************************/
class CrmPublicationHelpTest extends BaseTest
{
/*******************************************************************************
** Api matches every published table.
*******************************************************************************/
@Test
void apiMatchesEveryPublishedTable() throws Exception
{
QInstance instance = P4TestSupport.host();
instance.withSupplementalMetaData(new ApiInstanceMetaDataContainer().withApiInstanceMetaData(new ApiInstanceMetaData()
.withName("host").withPath("/api/").withLabel("Host API").withDescription("CRM test API").withContactEmail("test@example.invalid").withCurrentVersion(new APIVersion("v1")).withSupportedVersions(List.of(new APIVersion("v1")))));
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
.withApiName("host").withApiVersion("v1")).produce(instance).addSelfToInstance(instance);
new QInstanceValidator().validate(instance);
QContext.setQInstance(instance);
GetTableApiFieldsAction.clearCaches();
List<String> published = new ArrayList<>(CrmAppMetaData.MAIN_TABLES);
published.addAll(CrmAppMetaData.SETTINGS_TABLES);
for(String name : published)
{
var table = instance.getTable(name);
assertThat(ApiTableMetaDataContainer.of(table).getApiTableMetaData("host")).isNotNull();
var fields = GetTableApiFieldsAction.getTableApiFieldMap(new GetTableApiFieldsInput().withApiName("host").withVersion("v1").withTableName(name));
assertThat(fields.keySet()).as(name).containsExactlyInAnyOrderElementsOf(table.getFields().keySet());
}
assertThat(ApiTableMetaDataContainer.of(instance.getTable("crm_deal_product"))).isNull();
GetTableApiFieldsAction.clearCaches();
}
/*******************************************************************************
** Esb fails closed until core excludes classified values.
*******************************************************************************/
@Test
void esbFailsClosedUntilCoreExcludesClassifiedValues()
{
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig()
.withDefaultBackendNameForTables(BACKEND_NAME).withEsbDestinationName("crmEvents")).produce(P4TestSupport.host()))
.hasMessageContaining("classified-value exclusion");
}
/*******************************************************************************
** Main tables declare search fields.
*******************************************************************************/
@Test
void mainTablesDeclareSearchFields()
{
for(String table : CrmAppMetaData.MAIN_TABLES)
{
assertThat(QContext.getQInstance().getTable(table).getSearchFields()).isNotEmpty();
}
assertThat(QContext.getQInstance().getTable("crm_contact").getSearchFields()).containsExactly("firstName", "lastName", "email");
assertThat(QContext.getQInstance().getTable("crm_company").getSearchFields()).containsExactly("name", "domain");
}
/*******************************************************************************
** Help covers app every table main field process and enum.
*******************************************************************************/
@Test
void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception
{
var entries = CrmHelpBundleStandIn.entries();
var instance = QContext.getQInstance();
assertThat(entries).containsKey("app:crm;slot:help");
for(var table : instance.getTables().values())
{
assertThat(entries).containsKey("table:" + table.getName() + ";slot:help");
assertThat(table.getHelpContent().get("help")).isNotEmpty();
if(CrmAppMetaData.MAIN_TABLES.contains(table.getName()))
{
for(var field : table.getFields().values())
{
assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName());
assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty();
}
}
}
for(var process : instance.getProcesses().values())
{
if(process.getName().startsWith("crm_"))
{
assertThat(entries).containsKey("process:" + process.getName() + ";slot:help");
// Core creates dotted bulk-process names after QBit production; they retain core screen help.
if(!process.getName().contains("."))
{
process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast)
.forEach(step -> assertThat(step.getHelpContents()).isNotEmpty());
}
}
}
for(var pvs : instance.getPossibleValueSources().values())
{
if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm"))
{
assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help");
}
}
entries.values().forEach(entry ->
{
assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank();
assertThat(entry.get("content").asText()).isNotBlank();
});
}
}
@@ -29,10 +29,7 @@ package com.kingsrook.qbits.crm;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -50,26 +47,20 @@ class CrmQBitConfigTest
@Test
void testFluentSettersAndGetters()
{
List<QFieldMetaData> securityFields = List.of(
new QFieldMetaData("tenantId", QFieldType.INTEGER));
List<RecordSecurityLock> locks = List.of(
new RecordSecurityLock().withFieldName("tenantId"));
MetaDataCustomizerInterface<QTableMetaData> customizer = (qInstance, table) -> table;
CrmQBitConfig config = new CrmQBitConfig()
.withDefaultBackendNameForTables("myBackend")
.withInferBackendNames(false)
.withTableMetaDataCustomizer(customizer)
.withSecurityFields(securityFields)
.withRecordSecurityLocks(locks);
.withTenantSecurityKeyType("tenant")
.withTenantAllAccessKeyName("allTenants");
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("myBackend");
assertThat(config.getInferBackendNames()).isFalse();
assertThat(config.getTableMetaDataCustomizer()).isSameAs(customizer);
assertThat(config.getSecurityFields()).isSameAs(securityFields);
assertThat(config.getRecordSecurityLocks()).isSameAs(locks);
assertThat(config.getTenantSecurityKeyType()).isEqualTo("tenant");
assertThat(config.getTenantAllAccessKeyName()).isEqualTo("allTenants");
}
@@ -142,7 +142,7 @@ class CrmQBitProducerTest extends BaseTest
{
assertThat(QContext.getQInstance().getTable(tableName).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
}
assertThat(QContext.getQInstance().getTables()).hasSize(33)
assertThat(QContext.getQInstance().getTables()).hasSize(34)
.doesNotContainKeys("crm_tag", "crm_contact_tag", "crm_company_tag", "crm_deal_tag", "crm_attachment", "crm_audit_log");
assertThat(QContext.getQInstance().getProcesses()).doesNotContainKeys("crm_bulk_add_tag", "crm_bulk_remove_tag");
}
@@ -226,7 +226,7 @@ class CrmRdbmsTest
@Test
void testSchemaMatchesMetadata() throws Exception
{
assertThat(qInstance.getTables()).hasSize(33);
assertThat(qInstance.getTables()).hasSize(34);
for(QTableMetaData table : qInstance.getTables().values())
{
Set<String> columns = new HashSet<>();
@@ -270,20 +270,41 @@ class CrmRdbmsTest
/*******************************************************************************
** Both install paths contain identical SQL, with one Liquibase changeset per table.
** Fresh DDL and the full upgrade changelog produce identical column definitions.
*******************************************************************************/
@Test
void testLiquibaseMatchesReferenceDdl() throws Exception
{
String ddl = Files.readString(Path.of("docs/ddl/postgres.sql"));
String changelog = Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql"));
assertThat(changelog).startsWith("--liquibase formatted sql");
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(33);
assertThat(stripComments(changelog)).isEqualTo(stripComments(ddl));
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(35);
var freshColumns = schemaColumns();
executeSql("DROP ALL OBJECTS;");
executeSql(changelog);
assertThat(schemaColumns()).isEqualTo(freshColumns);
insert("crm_contact", new QRecord().withValue("email", "email-only@example.test"));
}
/*******************************************************************************
** Capture persisted types and nullability for both installation paths.
*******************************************************************************/
private java.util.Map<String, String> schemaColumns() throws Exception
{
java.util.Map<String, String> columns = new java.util.TreeMap<>();
try(ResultSet rows = connection.getMetaData().getColumns(null, "public", "crm_%", null))
{
while(rows.next())
{
columns.put(rows.getString("TABLE_NAME") + "." + rows.getString("COLUMN_NAME"),
rows.getString("TYPE_NAME") + ":" + rows.getInt("COLUMN_SIZE") + ":" + rows.getInt("NULLABLE"));
}
}
return (columns);
}
/*******************************************************************************
** Insert through QQQ and fail on record-level validation errors.
*******************************************************************************/
@@ -359,7 +380,7 @@ class CrmRdbmsTest
input.setWidgetMetaData(qInstance.getWidget("crm_pipeline_summary"));
TableData pipeline = (TableData) renderer.execute(input).getWidgetData();
assertThat(pipeline.getRows()).hasSize(2);
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open (1 deals)</a>");
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open</a>");
input.setWidgetMetaData(qInstance.getWidget("crm_deal_forecast"));
TableData forecast = (TableData) renderer.execute(input).getWidgetData();
assertThat(forecast.getRows()).hasSize(6);
@@ -370,4 +391,31 @@ class CrmRdbmsTest
assertThat(leaders.getRows().getFirst().get("first").toString()).endsWith(">Ada</a>");
}
/*******************************************************************************
** The base schema stays unchanged; only the explicit tenant migration adds keys.
*******************************************************************************/
@Test
void testOptionalTenantSchemaMatchesMetadata() throws Exception
{
try(var columns = connection.getMetaData().getColumns(null, null, "crm_contact", "tenant_key"))
{
assertThat(columns.next()).isFalse();
}
executeSql(Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql")));
QInstance tenantInstance = new QInstance();
tenantInstance.addBackend(qInstance.getBackend("rdbms"));
tenantInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables("rdbms")
.withTenantSecurityKeyType("tenant")).produce(tenantInstance).addSelfToInstance(tenantInstance);
new QInstanceValidator().validate(tenantInstance);
for(var table : tenantInstance.getTables().values())
{
try(var columns = connection.getMetaData().getColumns(null, null, table.getName(), "tenant_key"))
{
assertThat(columns.next()).as(table.getName()).isEqualTo(table.getFields().containsKey("tenantKey"));
}
}
}
}
@@ -0,0 +1,76 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Relationship validation must cover insert and either side of partial updates.
*******************************************************************************/
class CrmRelationshipValidationTest extends BaseTest
{
/*******************************************************************************
** Rejects wrong pipeline on insert and partial updates.
*******************************************************************************/
@Test
void rejectsWrongPipelineOnInsertAndPartialUpdates() throws Exception
{
P4TestSupport.seed("crm_pipeline_stage", new QRecord().withValue("id", 10).withValue("pipelineId", 1),
new QRecord().withValue("id", 20).withValue("pipelineId", 2));
var inserted = new InsertAction().execute(new InsertInput("crm_deal").withRecord(new QRecord().withValue("name", "Wrong stage")
.withValue("pipelineId", 1).withValue("pipelineStageId", 20)));
assertThat(inserted.getRecords().getFirst().getErrors()).isNotEmpty();
P4TestSupport.seed("crm_deal", new QRecord().withValue("id", 1).withValue("name", "Existing").withValue("pipelineId", 1).withValue("pipelineStageId", 10));
for(QRecord change : List.of(new QRecord().withValue("id", 1).withValue("pipelineId", 2),
new QRecord().withValue("id", 1).withValue("pipelineStageId", 20)))
{
assertThat(new UpdateAction().execute(new UpdateInput("crm_deal").withRecord(change)).getRecords().getFirst().getErrors()).isNotEmpty();
}
}
/*******************************************************************************
** Validates activity outcome and allows null outcome.
*******************************************************************************/
@Test
void validatesActivityOutcomeAndAllowsNullOutcome() throws Exception
{
P4TestSupport.seed("crm_activity_outcome", new QRecord().withValue("id", 10).withValue("activityTypeId", 1));
var bad = new InsertAction().execute(new InsertInput("crm_activity").withRecord(new QRecord().withValue("subject", "Wrong outcome")
.withValue("activityTypeId", 2).withValue("outcomeId", 10)));
assertThat(bad.getRecords().getFirst().getErrors()).isNotEmpty();
var good = new InsertAction().execute(new InsertInput("crm_activity").withRecord(new QRecord().withValue("subject", "Call").withValue("ownerUserId", TEST_USER_ID)
.withValue("activityTypeId", 1).withValue("outcomeId", 10)));
assertThat(good.getRecords().getFirst().getErrors()).isNullOrEmpty();
var cleared = new UpdateAction().execute(new UpdateInput("crm_activity").withRecord(new QRecord()
.withValue("id", good.getRecords().getFirst().getValue("id")).withValue("outcomeId", null)));
assertThat(cleared.getRecords().getFirst().getErrors()).isNullOrEmpty();
}
}
@@ -0,0 +1,67 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
/*******************************************************************************
** Isolated host instances and fixture inserts, without bypassing actions under test.
*******************************************************************************/
final class P4TestSupport
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private P4TestSupport()
{
}
/*******************************************************************************
** Build a host without CRM, ready for configuration-specific production.
*******************************************************************************/
static QInstance host()
{
QInstance instance = new QInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
return (instance);
}
/*******************************************************************************
** Fixture storage intentionally avoids business side effects.
*******************************************************************************/
static void seed(String table, QRecord... rows) throws QException
{
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecords(java.util.List.of(rows)), false);
}
}
@@ -0,0 +1,790 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.crm.actions.CrmAction;
import com.kingsrook.qbits.crm.actions.CrmActions;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.CrmRecords;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.email.CrmEmails;
import com.kingsrook.qbits.crm.email.processes.ExpireConsentsProcess;
import com.kingsrook.qbits.crm.email.processes.ProcessSequenceStepsProcess;
import com.kingsrook.qbits.crm.email.processes.SyncEmailDeliveryProcess;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
/*******************************************************************************
** P4 contracts exercised through memory DML, fake transport and real QAlerts dispatch.
*******************************************************************************/
class PlatformReuseTest extends BaseTest
{
private FakeTransport transport;
/*******************************************************************************
** Keep effects local to this test.
*******************************************************************************/
@BeforeEach
void configure() throws Exception
{
transport = new FakeTransport();
CrmApi.config().withPublicBaseUrl("https://crm.example.test").withTokenSecret("test-only-signing-key");
Sink.notifications.clear();
Sink.alerts.clear();
Sink.resolutions.clear();
AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance()).withSink(new QCodeReference(Sink.class));
}
/*******************************************************************************
** Send action renders and records transactional delivery.
*******************************************************************************/
@Test
void sendActionRendersAndRecordsTransactionalDelivery() throws Exception
{
QRecord contact = contact(1).withValue("companyId", 1);
put("crm_company", new QRecord().withValue("id", 1).withValue("name", "Example"));
put("crm_contact", contact);
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "Hello {{contact.firstName}}")
.withValue("bodyText", "{{contact.lastName}} at {{company.name}} / {{owner.name}} / {{unsubscribeUrl}}"));
CrmAction.Load load = load();
RunBackendStepInput input = new RunBackendStepInput().withProcessName("crm_send_email").withRecords(List.of(contact))
.addValue("emailTemplateId", 1).addValue("crmEmailProcessUUID", "run-123");
load.runOnePage(input, new RunBackendStepOutput());
assertThat(transport.key).isEqualTo("crm:email:1:run-123");
assertThat(transport.marketing).isFalse();
assertThat(transport.subject).isEqualTo("Hello Alice");
assertThat(transport.body).startsWith("Smith at Example / owner / https://crm.example.test/qqq/hooks/crm/unsubscribe/");
assertThat(rows("crm_email_message")).singleElement().satisfies(row ->
{
assertThat(row.getValueInteger("status")).isEqualTo(CrmEmailStatus.QUEUED.getId());
assertThat(row.getValueString("emailMessageId")).isEqualTo("provider-1");
assertThat(row.getValueString("idempotencyKey")).isEqualTo(transport.key);
});
assertActivity("EMAIL");
load.runOnePage(input, new RunBackendStepOutput());
assertThat(transport.sends).isEqualTo(1);
assertThat(rows("crm_activity")).hasSize(1);
}
/*******************************************************************************
** Overrides and missing templates and literal merge values.
*******************************************************************************/
@Test
void overridesAndMissingTemplatesAndLiteralMergeValues() throws Exception
{
QRecord contact = contact(1).withValue("firstName", "{{owner.name}}");
assertThat(CrmEmails.render("{{contact.firstName}} {{contact.lastName}}", contact)).isEqualTo("{{owner.name}} Smith");
assertThrows(QException.class, () -> CrmEmails.send(transport, contact, 999, null, null, "email", "missing", null));
assertThrows(QException.class, () -> CrmEmails.send(transport, contact, null, "subject", "", "email", "missing", null));
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "template").withValue("bodyText", "template"));
CrmEmails.send(transport, contact, 1, "override", "body", "email", "1:run", null);
assertThat(transport.subject).isEqualTo("override");
assertThat(transport.body).isEqualTo("body");
}
/*******************************************************************************
** Skips opt out missing address and unavailable transport.
*******************************************************************************/
@Test
void skipsOptOutMissingAddressAndUnavailableTransport() throws Exception
{
List<QRecord> contacts = List.of(contact(1).withValue("doNotEmail", true), contact(2).withValue("email", null), contact(3));
for(QRecord contact : contacts)
{
put("crm_contact", contact);
}
transport.ready = false;
CrmAction.Load load = load();
RunBackendStepOutput output = new RunBackendStepOutput();
load.runOnePage(new RunBackendStepInput().withProcessName("crm_send_email").withRecords(contacts), output);
assertThat(load.getProcessSummary(output, true)).hasSize(3);
assertThat(transport.sends).isZero();
assertThat(rows("crm_email_message")).isEmpty();
assertThat(rows("crm_activity")).isEmpty();
}
/*******************************************************************************
** Process identity uses core uuid.
*******************************************************************************/
@Test
void processIdentityUsesCoreUuid() throws Exception
{
var output = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_send_email"));
assertThat(output.getException()).isEmpty();
assertThat(output.getValues().get("crmEmailProcessUUID")).isEqualTo(output.getProcessUUID());
var listener = new CrmEmailProcessIdentity();
assertThat(listener.appliesTo("crm_send_email")).isTrue();
assertThat(listener.appliesTo("crm_other")).isFalse();
listener.onProcessCompleted(new RunProcessInput(), output);
listener.onProcessFailed(new RunProcessInput(), new Exception("test"));
assertThat(CrmActions.inputFields(CrmActions.action("crm_send_email"))).extracting(field -> field.getName())
.containsExactly("emailTemplateId", "subject", "body");
}
/*******************************************************************************
** Active sequence sends marketing and advances once.
*******************************************************************************/
@Test
void activeSequenceSendsMarketingAndAdvancesOnce() throws Exception
{
seedSequence(true, true);
sequence();
assertThat(transport.key).isEqualTo("crm:seq:1:1");
assertThat(transport.marketing).isTrue();
assertThat(rows("crm_email_message")).hasSize(1);
assertActivity("SEQUENCE_STEP");
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.COMPLETED.getId());
sequence();
assertThat(transport.sends).isEqualTo(1);
}
/*******************************************************************************
** Paused sequence does not advance or send.
*******************************************************************************/
@Test
void pausedSequenceDoesNotAdvanceOrSend() throws Exception
{
seedSequence(false, true);
sequence();
assertThat(transport.sends).isZero();
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("currentStepNumber")).isZero();
assertThat(Sink.alerts).isEmpty();
}
/*******************************************************************************
** Missing consent pauses enrollment with reason.
*******************************************************************************/
@Test
void missingConsentPausesEnrollmentWithReason() throws Exception
{
seedSequence(true, false);
sequence();
QRecord enrollment = GetAction.execute("crm_sequence_enrollment", 1);
assertThat(enrollment.getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.PAUSED.getId());
assertThat(enrollment.getValueString("lastFailureMessage")).isEqualTo("no_email_consent");
assertThat(enrollment.getValueInteger("failureCount")).isZero();
assertThat(transport.sends).isZero();
}
/*******************************************************************************
** Transport failures raise on third attempt.
*******************************************************************************/
@Test
void transportFailuresRaiseOnThirdAttempt() throws Exception
{
seedSequence(true, true);
transport.fail = true;
sequence();
sequence();
sequence();
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("failureCount")).isEqualTo(3);
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.FAILED.getId());
assertThat(Sink.alerts).singleElement().satisfies(signal ->
{
assertThat(signal.getAlertTypeName()).isEqualTo("crm.sequence.failing");
assertThat(signal.getDedupKey()).isEqualTo("enrollment:1");
});
assertThat(rows("crm_email_message")).isEmpty();
}
/*******************************************************************************
** Unavailable transport raises then resolves without failing enrollment.
*******************************************************************************/
@Test
void unavailableTransportRaisesThenResolvesWithoutFailingEnrollment() throws Exception
{
seedSequence(true, true);
transport.ready = false;
sequence();
assertThat(Sink.alerts).singleElement().satisfies(signal ->
{
assertThat(signal.getAlertTypeName()).isEqualTo("crm.email.unavailable");
assertThat(signal.getDedupKey()).isEqualTo("transport");
});
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("failureCount")).isZero();
transport.ready = true;
sequence();
assertThat(Sink.resolutions).hasSize(1);
assertThat(transport.sends).isEqualTo(1);
}
/*******************************************************************************
** Delivery sync uses recent nonfinal messages and preserves dates.
*******************************************************************************/
@Test
void deliverySyncUsesRecentNonfinalMessagesAndPreservesDates() throws Exception
{
contact(1);
message(1, CrmEmailStatus.SENT, Instant.now());
message(2, CrmEmailStatus.SENT, Instant.now().minus(31, ChronoUnit.DAYS));
message(3, CrmEmailStatus.FAILED, Instant.now());
Instant now = Instant.now();
transport.deliveries.put("key:1", new CrmEmailTransport.Delivery("provider", CrmEmailStatus.CLICKED, now, now, now, now, null));
sync();
assertThat(transport.lookups).containsExactly("key:1");
QRecord message = GetAction.execute("crm_email_message", 1);
assertThat(message.getValueInstant("deliveredDate")).isEqualTo(now);
assertThat(message.getValueInstant("firstOpenedDate")).isEqualTo(now);
assertThat(message.getValueInstant("firstClickedDate")).isEqualTo(now);
transport.deliveries.put("key:1", new CrmEmailTransport.Delivery("provider", CrmEmailStatus.CLICKED, null, null, null, null, null));
sync();
assertThat(GetAction.execute("crm_email_message", 1).getValueInstant("firstOpenedDate")).isEqualTo(now);
}
/*******************************************************************************
** Bounce and complaint withdraw once and suppress contact.
*******************************************************************************/
@Test
void bounceAndComplaintWithdrawOnceAndSuppressContact() throws Exception
{
for(int id = 1; id <= 2; id++)
{
contact(id);
message(id, CrmEmailStatus.DELIVERED, Instant.now());
CrmEmailStatus status = id == 1 ? CrmEmailStatus.BOUNCED : CrmEmailStatus.COMPLAINED;
transport.deliveries.put("key:" + id, new CrmEmailTransport.Delivery("provider", status, null, null, null, null, Instant.now()));
}
sync();
sync();
assertThat(rows("crm_contact")).allSatisfy(row -> assertThat(row.getValueBoolean("doNotEmail")).isTrue());
assertThat(rows("crm_consent_record")).hasSize(2).allSatisfy(row ->
{
assertThat(row.getValueInteger("consentType")).isEqualTo(CrmConsentType.EMAIL.getId());
assertThat(row.getValueInteger("status")).isEqualTo(CrmConsentStatus.WITHDRAWN.getId());
});
assertThat(rows("crm_consent_record")).extracting(row -> row.getValueString("source")).containsExactlyInAnyOrder("Hard bounce", "Complaint");
}
/*******************************************************************************
** Absent email qbit is no op.
*******************************************************************************/
@Test
void absentEmailQbitIsNoOp() throws Exception
{
message(1, CrmEmailStatus.SENT, Instant.now());
RunBackendStepOutput output = new RunBackendStepOutput();
new SyncEmailDeliveryProcess().run(new RunBackendStepInput(), output);
assertThat(output.getValueInteger("updatedCount")).isZero();
assertThat(new CrmEmailTransport(CrmApi.config()).lookup("key:1")).isEmpty();
}
/*******************************************************************************
** Stale pending members expire without new consent rows.
*******************************************************************************/
@Test
void stalePendingMembersExpireWithoutNewConsentRows() throws Exception
{
for(int id = 1; id <= 3; id++)
{
put("crm_email_list_member", new QRecord().withValue("id", id).withValue("emailListId", 1).withValue("email", "a" + id + "@example.test")
.withValue("status", id == 3 ? CrmListMemberStatus.SUBSCRIBED.getId() : CrmListMemberStatus.PENDING_CONFIRMATION.getId())
.withValue("confirmationSentDate", Instant.now().minus(id == 2 ? 13 : 15, ChronoUnit.DAYS)).withValue("confirmationTokenHash", "hash"));
}
put("crm_consent_record", new QRecord().withValue("id", 1).withValue("expiryDate", Instant.now().minusSeconds(1)));
RunBackendStepOutput output = new RunBackendStepOutput();
new ExpireConsentsProcess().run(new RunBackendStepInput(), output);
assertThat(output.getValueInteger("expiredPendingCount")).isEqualTo(1);
assertThat(output.getValueInteger("expiredConsentCount")).isEqualTo(1);
assertThat(GetAction.execute("crm_email_list_member", 1).getValue("confirmationTokenHash")).isNull();
assertThat(GetAction.execute("crm_email_list_member", 1).getValueInteger("status")).isEqualTo(CrmListMemberStatus.UNSUBSCRIBED.getId());
assertThat(GetAction.execute("crm_email_list_member", 2).getValueString("confirmationTokenHash")).isEqualTo("hash");
assertThat(rows("crm_consent_record")).hasSize(1);
}
/*******************************************************************************
** Schedules require scheduler and can be disabled.
*******************************************************************************/
@Test
void schedulesRequireSchedulerAndCanBeDisabled() throws Exception
{
Map<String, Integer> intervals = Map.of("crm_process_sequence_steps", 300, "crm_calculate_lead_scores", 3600,
"crm_sync_email_delivery", 900, "crm_expire_consents", 86400);
for(String name : intervals.keySet())
{
assertThat(QContext.getQInstance().getProcess(name).getSchedule()).isNull();
}
CrmQBitConfig config = new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME).withSchedulerName("jobs");
var output = new CrmQBitProducer().withQBitConfig(config).produce(new com.kingsrook.qqq.backend.core.model.metadata.QInstance());
for(var process : output.getEach(com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData.class))
{
if(intervals.containsKey(process.getName()))
{
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(intervals.get(process.getName()));
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("jobs");
}
}
assertThat(config.getSchedulesEnabled()).isTrue();
output = new CrmQBitProducer().withQBitConfig(config.withSchedulesEnabled(false)).produce(new com.kingsrook.qqq.backend.core.model.metadata.QInstance());
assertThat(output.getEach(com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData.class))
.allSatisfy(process -> assertThat(process.getSchedule()).isNull());
}
/*******************************************************************************
** Collaboration and alerts use core metadata.
*******************************************************************************/
@Test
void collaborationAndAlertsUseCoreMetadata() throws Exception
{
for(String table : List.of("crm_contact", "crm_company", "crm_deal", "crm_activity"))
{
var features = RecordFeaturesMetaData.of(QContext.getQInstance().getTable(table));
for(RecordFeature feature : List.of(RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.ATTACHMENTS,
RecordFeature.FOLLOW, RecordFeature.TIMELINE, RecordFeature.RECENTS, RecordFeature.FAVORITES))
{
assertThat(features.getEnabled().get(feature)).isTrue();
}
assertThat(features.getOwnership().getOwnerFieldName()).isEqualTo("ownerUserId");
assertThat(features.getEnabled().get(RecordFeature.TASKS)).isEqualTo("crm_activity".equals(table) ? null : true);
assertThat(features.getEnabled().get(RecordFeature.ASSIGNMENT)).isEqualTo("crm_activity".equals(table) ? null : true);
}
assertThat(AlertsInstanceMetaData.of(QContext.getQInstance()).getAlertTypes()).containsKeys("crm.sequence.failing", "crm.email.unavailable",
"crm.lead.assigned", "crm.deal.won", "crm.deal.rotting", "crm.task.due");
}
/*******************************************************************************
** Scoring uses dates incremental changes and daily full pass.
*******************************************************************************/
@Test
void scoringUsesDatesIncrementalChangesAndDailyFullPass() throws Exception
{
put("crm_contact", contact(1).withValue("lastActivityDate", Instant.now().minus(20, ChronoUnit.DAYS)).withValue("modifyDate", Instant.now().minusSeconds(300)));
put("crm_contact", contact(2).withValue("lastActivityDate", Instant.now().minus(2, ChronoUnit.DAYS)).withValue("modifyDate", Instant.now().minusSeconds(300)));
rule(1, "lastActivityDate", CrmScoreOperator.DAYS_AGO_GT, "10", -5);
rule(2, "lastActivityDate", CrmScoreOperator.DAYS_AGO_LT, "10", 10);
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(2);
assertThat(GetAction.execute("crm_contact", 1).getValueInteger("leadScore")).isEqualTo(-5);
assertThat(GetAction.execute("crm_contact", 2).getValueInteger("leadScore")).isEqualTo(10);
assertThat(scores(false).getValueInteger("totalContacts")).isZero();
CrmRecords.update("crm_contact", new QRecord().withValue("id", 2).withValue("firstName", "Changed"));
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(1);
assertThat(scores(true).getValueInteger("totalContacts")).isEqualTo(2);
QRecord state = rows("crm_job_state").getFirst();
CrmRecords.update("crm_job_state", state.withValue("lastFullPass", Instant.now().minus(25, ChronoUnit.HOURS)));
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(2);
}
/*******************************************************************************
** Scoring handles calendar dates and malformed rules.
*******************************************************************************/
@Test
void scoringHandlesCalendarDatesAndMalformedRules() throws Exception
{
put("crm_contact", contact(1).withValue("lastName", LocalDate.now().minusDays(30).toString()));
rule(1, "lastName", CrmScoreOperator.DAYS_AGO_GT, "10", 7);
rule(2, "firstName", CrmScoreOperator.DAYS_AGO_LT, "bad", 100);
rule(3, "email", CrmScoreOperator.DAYS_AGO_GT, "10", 100);
scores(true);
assertThat(GetAction.execute("crm_contact", 1).getValueInteger("leadScore")).isEqualTo(7);
}
/*******************************************************************************
** Insert assignment preserves owners and notifies.
*******************************************************************************/
@Test
void insertAssignmentPreservesOwnersAndNotifies() throws Exception
{
for(CrmAssignableEntity entity : CrmAssignableEntity.values())
{
put("crm_assignment_rule", new QRecord().withValue("id", entity.getId()).withValue("name", "Assign")
.withValue("entityType", entity.getId()).withValue("assignStrategy", CrmAssignStrategy.SPECIFIC_USER.getId())
.withValue("assignToUserId", "assigned").withValue("isActive", true).withValue("sortOrder", 1));
}
QRecord inserted = CrmRecords.insert("crm_contact", new QRecord().withValue("firstName", "New").withValue("lastName", "Contact"));
assertThat(GetAction.execute("crm_contact", inserted.getValue("id")).getValueString("ownerUserId")).isEqualTo("assigned");
CrmRecords.insert("crm_contact", new QRecord().withValue("firstName", "Already").withValue("lastName", "Owned").withValue("ownerUserId", "explicit"));
put("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales"));
put("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("pipelineId", 1).withValue("name", "Open"));
QRecord deal = CrmRecords.insert("crm_deal", new QRecord().withValue("name", "New deal").withValue("pipelineId", 1)
.withValue("pipelineStageId", 1).withValue("ownerUserId", "system").withValue("currencyCode", "USD"));
assertThat(GetAction.execute("crm_deal", deal.getValue("id")).getValueString("ownerUserId")).isEqualTo("assigned");
assertThat(Sink.notifications).hasSize(2).allSatisfy(signal ->
{
assertThat(signal.getSourceName()).isEqualTo("crm.lead.assigned");
assertThat(signal.getAudience()).isEqualTo(NotificationAudience.user("assigned"));
});
}
/*******************************************************************************
** Won and rotting notifications target owner and deduplicate stage.
*******************************************************************************/
@Test
void wonAndRottingNotificationsTargetOwnerAndDeduplicateStage() throws Exception
{
put("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("name", "Open").withValue("pipelineId", 1).withValue("rotDays", 5));
put("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("name", "Won").withValue("pipelineId", 1).withValue("isClosedWon", true));
put("crm_deal", new QRecord().withValue("id", 1).withValue("name", "Old deal").withValue("pipelineId", 1)
.withValue("pipelineStageId", 1).withValue("ownerUserId", "owner").withValue("stageEnteredDate", Instant.now().minus(10, ChronoUnit.DAYS)));
CrmDealNotifications.checkRotting();
CrmDealNotifications.checkRotting();
assertThat(Sink.notifications).singleElement().satisfies(signal -> assertThat(signal.getSourceKey()).isEqualTo("deal:1:stage:1"));
CrmRecords.update("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 2));
assertThat(Sink.notifications).hasSize(2);
assertThat(Sink.notifications.getLast().getSourceName()).isEqualTo("crm.deal.won");
assertThat(Sink.notifications.getLast().getAudience()).isEqualTo(NotificationAudience.user("owner"));
CrmRecords.update("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 2));
CrmDealNotifications.checkRotting();
assertThat(Sink.notifications).hasSize(2);
}
/*******************************************************************************
** Company loading scales with pages, not contacts or rules.
*******************************************************************************/
@Test
void scoringLoadsCompaniesOncePerPage() throws Exception
{
put("crm_company", new QRecord().withValue("id", 1).withValue("name", "Example"));
for(int id = 1; id <= 201; id++)
{
put("crm_contact", contact(id).withValue("companyId", 1));
}
rule(1, "company.name", CrmScoreOperator.EQUALS, "Example", 3);
rule(2, "company.name", CrmScoreOperator.IS_SET, null, 2);
CompanyReads.count = 0;
QContext.getQInstance().getTable("crm_company").withCustomizer(
com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD, new QCodeReference(CompanyReads.class));
assertThat(scores(true).getValueInteger("updatedCount")).isEqualTo(201);
// Two explicit page loads, plus at most two core audit-label translations.
assertThat(CompanyReads.count).isBetween(2, 4);
assertThat(GetAction.execute("crm_contact", 201).getValueInteger("leadScore")).isEqualTo(5);
}
/*******************************************************************************
** Failed scoring must leave the successful checkpoint unchanged.
*******************************************************************************/
@Test
void scoringFailureDoesNotAdvanceCheckpoint() throws Exception
{
contact(1);
scores(true);
Instant checkpoint = rows("crm_job_state").getFirst().getValueInstant("lastSuccess");
rule(1, "firstName", CrmScoreOperator.IS_SET, null, 1);
QContext.getQInstance().getTable("crm_contact").withCustomizer(
com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(RejectScore.class));
assertThrows(QException.class, () -> scores(true));
assertThat(rows("crm_job_state").getFirst().getValueInstant("lastSuccess")).isEqualTo(checkpoint);
}
/*******************************************************************************
** Retry repairs a missing activity without sending the persisted message again.
*******************************************************************************/
@Test
void retryRepairsActivityAfterPartialRecording() throws Exception
{
QRecord contact = contact(1);
put("crm_email_message", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("subject", "Already sent")
.withValue("idempotencyKey", "crm:email:1:retry").withValue("status", CrmEmailStatus.SENT.getId()));
CrmEmails.send(transport, contact, null, "Already sent", "body", "email", "1:retry", null);
assertThat(transport.sends).isZero();
assertActivity("EMAIL");
assertThat(GetAction.execute("crm_email_message", 1).getValue("activityId")).isNotNull();
}
/*******************************************************************************
** Count actual backend company queries.
*******************************************************************************/
public static class CompanyReads extends com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer
{
private static int count;
@Override
public List<QRecord> apply(List<QRecord> records)
{
count++;
return (records);
}
}
/*******************************************************************************
** Return a record-level error to exercise checked background writes.
*******************************************************************************/
public static class RejectScore extends com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer
{
@Override
public List<QRecord> apply(List<QRecord> records)
{
records.forEach(row -> row.addError(new com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage("Rejected score")));
return (records);
}
}
/*******************************************************************************
** Send action with a local fake.
*******************************************************************************/
private CrmAction.Load load()
{
return (new CrmAction.Load()
{
@Override
protected CrmEmailTransport emailTransport()
{
return (transport);
}
});
}
/*******************************************************************************
** Sequence runner with a local fake.
*******************************************************************************/
private void sequence() throws QException
{
new ProcessSequenceStepsProcess()
{
@Override
protected CrmEmailTransport emailTransport()
{
return (transport);
}
}.run(new RunBackendStepInput(), new RunBackendStepOutput());
}
/*******************************************************************************
** Sync with a local fake.
*******************************************************************************/
private void sync() throws QException
{
new SyncEmailDeliveryProcess()
{
@Override
protected CrmEmailTransport emailTransport()
{
return (transport);
}
}.run(new RunBackendStepInput(), new RunBackendStepOutput());
}
/*******************************************************************************
** Run score work using normal QQQ actions.
*******************************************************************************/
private RunBackendStepOutput scores(boolean full) throws QException
{
RunBackendStepOutput output = new RunBackendStepOutput();
new CalculateLeadScoresProcess().run(new RunBackendStepInput().addValue("fullRecalc", full), output);
return (output);
}
/*******************************************************************************
** Contact fixture.
*******************************************************************************/
private QRecord contact(int id) throws QException
{
QRecord row = new QRecord().withValue("id", id).withValue("firstName", "Alice").withValue("lastName", "Smith")
.withValue("email", "alice" + id + "@example.test").withValue("ownerUserId", "owner");
put("crm_contact", row);
return (row);
}
/*******************************************************************************
** Sequence fixtures use enum identifiers.
*******************************************************************************/
private void seedSequence(boolean active, boolean eligible) throws QException
{
contact(1);
put("crm_email_sequence", new QRecord().withValue("id", 1).withValue("name", "Sequence").withValue("isActive", active).withValue("totalSteps", 1));
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "Hello {{contact.firstName}}").withValue("bodyText", "Welcome"));
put("crm_sequence_step", new QRecord().withValue("id", 1).withValue("sequenceId", 1).withValue("stepNumber", 1)
.withValue("stepType", CrmSequenceStepType.EMAIL.getId()).withValue("emailTemplateId", 1));
put("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("sequenceId", 1).withValue("contactId", 1)
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("currentStepNumber", 0).withValue("failureCount", 0)
.withValue("nextStepDate", Instant.now().minusSeconds(60)));
if(eligible)
{
put("crm_consent_record", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("consentType", CrmConsentType.EMAIL.getId())
.withValue("status", CrmConsentStatus.GRANTED.getId()).withValue("consentDate", Instant.now()));
}
}
/*******************************************************************************
** Transport-linked message fixture.
*******************************************************************************/
private void message(int id, CrmEmailStatus status, Instant created) throws QException
{
put("crm_email_message", new QRecord().withValue("id", id).withValue("contactId", id).withValue("subject", "Test")
.withValue("idempotencyKey", "key:" + id).withValue("status", status.getId()).withValue("createDate", created));
}
/*******************************************************************************
** Score rule fixture.
*******************************************************************************/
private void rule(int id, String field, CrmScoreOperator operator, String expected, int points) throws QException
{
put("crm_lead_score_rule", new QRecord().withValue("id", id).withValue("name", "Rule " + id).withValue("fieldPath", field)
.withValue("operator", operator.getId()).withValue("fieldValue", expected).withValue("scoreAdjustment", points).withValue("isActive", true));
}
/*******************************************************************************
** Assert the activity's stable code.
*******************************************************************************/
private void assertActivity(String code) throws QException
{
QRecord activity = rows("crm_activity").getFirst();
assertThat(GetAction.execute("crm_activity_type", activity.getValue("activityTypeId")).getValueString("code")).isEqualTo(code);
}
/*******************************************************************************
** Insert isolated fixture data.
*******************************************************************************/
private void put(String table, QRecord row) throws QException
{
if(row.getValue("id") != null && GetAction.execute(table, row.getValue("id")) != null)
{
MemoryRecordStore.getInstance().update(new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(table).withRecord(row), true);
}
else
{
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(row), true);
}
}
/*******************************************************************************
** Query in the current session.
*******************************************************************************/
private List<QRecord> rows(String table) throws QException
{
return (new QueryAction().execute(new QueryInput(table)).getRecords());
}
/*******************************************************************************
** Fake transport with independent send and delivery controls.
*******************************************************************************/
private static class FakeTransport extends CrmEmailTransport
{
private boolean ready = true;
private boolean fail;
private boolean marketing;
private int sends;
private String key;
private String subject;
private String body;
private final Map<String, Delivery> deliveries = new HashMap<>();
private final List<String> lookups = new ArrayList<>();
/*******************************************************************************
** No external provider is installed.
*******************************************************************************/
FakeTransport()
{
super(new CrmQBitConfig());
}
@Override
public CrmReadiness readiness()
{
return (ready ? CrmReadiness.QBIT_EMAIL : CrmReadiness.NOT_CONFIGURED);
}
@Override
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
{
if(fail)
{
throw (new QException("Fake transport failure"));
}
sends++;
key = "crm:" + purpose + ":" + id;
this.subject = subject;
this.body = body;
this.marketing = marketing;
return (new Delivery("provider-1", CrmEmailStatus.QUEUED, null, null, null, null, null));
}
@Override
public Optional<Delivery> lookup(String key)
{
lookups.add(key);
return (Optional.ofNullable(deliveries.get(key)));
}
}
/*******************************************************************************
** Capture the core dispatch without an alerts backend.
*******************************************************************************/
public static class Sink extends LoggingAlertSink
{
private static final List<NotificationSignal> notifications = new ArrayList<>();
private static final List<AlertSignal> alerts = new ArrayList<>();
private static final List<AlertResolution> resolutions = new ArrayList<>();
@Override
public void notify(NotificationSignal signal)
{
notifications.add(signal);
}
@Override
public void raise(AlertSignal signal)
{
alerts.add(signal);
}
@Override
public void resolve(AlertResolution resolution)
{
resolutions.add(resolution);
}
}
}
@@ -133,7 +133,7 @@ class CrmActionsTest extends BaseTest
void metadataIsComplete()
{
assertThat(CrmActions.ACTIONS).hasSize(28);
assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(30);
assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(32);
for(CrmActions.Action action : CrmActions.ACTIONS)
{
QProcessMetaData process = QContext.getQInstance().getProcess(action.name());
@@ -154,7 +154,7 @@ class CrmActionsTest extends BaseTest
assertThat(process.getMaxInputRecords()).isEqualTo(2);
}
}
for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores"))
for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores", "crm_sync_email_delivery", "crm_expire_consents"))
{
assertThat(QContext.getQInstance().getProcess(name).getTableName()).isNull();
}
@@ -386,7 +386,9 @@ class CrmActionsTest extends BaseTest
put("crm_contact", new QRecord().withValue("id", 10 + i * 2).withValue("lastName", "Contact").withValue("ownerUserId", "original"));
}
PageObserver.sizes.clear();
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class));
QContext.getQInstance().getTable("crm_contact").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole());
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer.of(
new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class), new QCodeReference(PageObserver.class)));
RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_calculate_lead_scores"));
assertThat(result.getValues().get("totalContacts")).isEqualTo(405);
assertThat(result.getValues().get("updatedCount")).isEqualTo(405);
@@ -99,6 +99,23 @@ public class CrmApisTest extends BaseTest
}
/*******************************************************************************
** Email-only intake preserves absent names and never assigns the service user.
*******************************************************************************/
@Test
void emailOnlyIntakeHasNoOwner() throws Exception
{
QContext.getQSession().setIdReference("site-api-system");
CrmFormResult result = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, null, " ", "only@example.test",
null, null, "Hello", false, "Reply", null, null, null, null, null, null, null, null, null));
QRecord contact = CrmStore.asUser().get("crm_contact", result.contactId());
assertThat(contact.getValue("firstName")).isNull();
assertThat(contact.getValue("lastName")).isNull();
assertThat(contact.getValue("ownerUserId")).isNull();
assertThat(contact.getRecordLabel()).isEqualTo("only@example.test");
}
/*******************************************************************************
** Form preserves first touch and never grants implicit marketing consent.
*******************************************************************************/
@@ -530,7 +547,7 @@ public class CrmApisTest extends BaseTest
assertThat(CrmStore.trusted().by("crm_contact", "email", "subscriber@example.test").get(0).getValueString("ownerUserId")).isEqualTo("sales-0");
QContext.getQSession().setIdReference("named-owner");
Integer owned = intake.submitForm(form("owned@example.test", false, "owned")).contactId();
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("named-owner");
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("sales-1");
CrmStore.trusted().update("crm_contact", new QRecord().withValue("id", first.contactId()).withValue("ownerUserId", "system"));
intake.submitForm(form("first@example.test", false, "existing-unowned"));
assertThat(CrmStore.trusted().get("crm_contact", first.contactId()).getValueString("ownerUserId")).isEqualTo("system");
@@ -127,7 +127,7 @@ class ContactTest extends BaseTest
///////////////////////////////////
// verify T1 identity section //
///////////////////////////////////
assertThat(table.getSections().get(0).getFieldNames()).contains("id", "firstName", "lastName", "email", "phone");
assertThat(table.getSections().get(0).getFieldNames()).contains("firstName", "lastName", "email", "phone");
}
@@ -119,6 +119,30 @@ class DealInitializationCustomizerTest extends BaseTest
/*******************************************************************************
** Lost-stage inserts close immediately while imported dates are preserved.
*******************************************************************************/
@Test
void closedStageDates() throws QException
{
Integer pipeline = insertPipeline("Closed imports");
Integer stage = new InsertAction().execute(new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(new PipelineStage()
.withPipelineId(pipeline).withName("Lost").withSortOrder(1).withProbabilityPct(0).withIsClosedLost(true)))
.getRecords().getFirst().getValueInteger("id");
Deal lost = new Deal(GetAction.execute(Deal.TABLE_NAME, insertDeal("Lost", pipeline, stage, BigDecimal.TEN)));
assertEquals(java.time.LocalDate.now(java.time.ZoneOffset.UTC), lost.getActualCloseDate());
assertNotNull(lost.getStageEnteredDate());
java.time.LocalDate closeDate = java.time.LocalDate.of(2025, 1, 2);
java.time.Instant entered = java.time.Instant.parse("2025-01-01T10:00:00Z");
Integer imported = new InsertAction().execute(new InsertInput(Deal.TABLE_NAME).withRecordEntity(new Deal()
.withName("Imported").withPipelineId(pipeline).withPipelineStageId(stage).withActualCloseDate(closeDate).withStageEnteredDate(entered)))
.getRecords().getFirst().getValueInteger("id");
Deal saved = new Deal(GetAction.execute(Deal.TABLE_NAME, imported));
assertEquals(closeDate, saved.getActualCloseDate());
assertEquals(entered, saved.getStageEnteredDate());
}
/////////////////////////////////////////////////////////////////////////////
// Helper methods //
/////////////////////////////////////////////////////////////////////////////
@@ -60,10 +60,10 @@ class ProcessSequenceStepsProcessTest extends BaseTest
{
/*******************************************************************************
** Email step should create an activity record.
** Email without recorded consent pauses without creating a fake activity.
*******************************************************************************/
@Test
void testEmailStepCreatesActivity() throws QException
void testEmailStepRequiresConsent() throws QException
{
insertActivityType("Email");
insertActivityType("Task");
@@ -91,8 +91,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest
QueryOutput activityQuery = new QueryAction().execute(
new QueryInput(Activity.TABLE_NAME));
assertThat(activityQuery.getRecords()).isNotEmpty();
assertThat(activityQuery.getRecords().get(0).getValueString("subject")).contains("Sequence email");
assertThat(activityQuery.getRecords()).isEmpty();
/////////////////////////////////////////////
// verify enrollment was advanced //
@@ -101,7 +100,8 @@ class ProcessSequenceStepsProcessTest extends BaseTest
new QueryInput(SequenceEnrollment.TABLE_NAME));
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentQuery.getRecords().get(0));
assertEquals(1, enrollment.getCurrentStepNumber());
assertEquals(0, enrollment.getCurrentStepNumber());
assertEquals(CrmEnrollmentStatus.PAUSED.getId(), enrollment.getStatus());
}
@@ -142,10 +142,10 @@ class ProcessSequenceStepsProcessTest extends BaseTest
/*******************************************************************************
** After 3 failures, enrollment should be marked as FAILED.
** Opt-out pauses an enrollment without counting it as a delivery failure.
*******************************************************************************/
@Test
void testFailureCounting() throws QException
void testOptOutPausesWithoutCountingFailure() throws QException
{
Integer sequenceId = insertSequence("Fail Seq", true, 2, false);
Integer contactId = insertContact("Carol", "Fail", true); // doNotEmail = true
@@ -181,8 +181,8 @@ class ProcessSequenceStepsProcessTest extends BaseTest
new QueryInput(SequenceEnrollment.TABLE_NAME));
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentQuery.getRecords().get(0));
assertEquals(CrmEnrollmentStatus.FAILED.getId(), enrollment.getStatus());
assertEquals(3, enrollment.getFailureCount());
assertEquals(CrmEnrollmentStatus.PAUSED.getId(), enrollment.getStatus());
assertEquals(2, enrollment.getFailureCount());
assertThat(enrollment.getLastFailureMessage()).isNotNull();
}
@@ -64,6 +64,26 @@ class CrmContactTimelineWidgetTest extends BaseTest
}
/*******************************************************************************
** Session zones observe DST; missing or invalid zones fall back to UTC.
*******************************************************************************/
@Test
void readableDates()
{
Instant time = Instant.parse("2026-10-14T20:19:01.142263Z");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
QContext.getQSession().setValue("UserTimezone", "America/Chicago");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 3:19 PM");
assertThat(CrmContactTimelineRenderer.formatDate(Instant.parse("2026-01-14T20:19:00Z"))).isEqualTo("Jan 14, 2026 2:19 PM");
QContext.getQSession().setValue("UserTimezone", "invalid");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
QContext.getQSession().setValue("UserTimezone", "");
QContext.getQSession().setValue("UserTimezoneOffsetMinutes", "120");
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 10:19 PM");
assertThat(CrmContactTimelineRenderer.formatDate(null)).isEmpty();
}
/*******************************************************************************
** Missing and nonexistent parent ids never query all contacts' histories.
*******************************************************************************/
@@ -82,6 +82,54 @@ class CrmHomeWidgetsTest extends BaseTest
}
/*******************************************************************************
** Whole and fractional amounts retain grouping without artificial decimals.
*******************************************************************************/
@Test
void amountFormatting()
{
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("72000.000000"))).isEqualTo("72,000");
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("1234.560000"))).isEqualTo("1,234.56");
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("-1234.005"))).isEqualTo("-1,234.005");
assertThat(CrmHomeRenderer.formatAmount(null)).isEqualTo("0");
}
/*******************************************************************************
** A direct won-stage insert participates in actual-close reporting immediately.
*******************************************************************************/
@Test
void directWonInsert() throws Exception
{
seedStages();
seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
var inserted = new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(new InsertInput("crm_deal")
.withRecord(new QRecord().withValue("name", "Direct win").withValue("pipelineId", 1).withValue("pipelineStageId", 2)
.withValue("amount", new BigDecimal("72000")).withValue("expectedCloseDate", LocalDate.now().plusYears(1))));
assertThat(inserted.getRecords().getFirst().getErrors()).isNullOrEmpty();
var deal = com.kingsrook.qqq.backend.core.actions.tables.GetAction.execute("crm_deal", inserted.getRecords().getFirst().getValue("id"));
assertThat(deal.getValue("ownerUserId")).isNull();
assertThat(deal.getValueLocalDate("actualCloseDate")).isEqualTo(LocalDate.now(ZoneOffset.UTC));
assertThat(deal.getValueInstant("stageEnteredDate")).isNotNull();
assertThat(((StatisticsData) render("crm_won_month")).getCount()).isEqualTo("USD 72,000");
TableData leaderboard = (TableData) render("crm_sales_leaderboard");
assertCell(leaderboard, 0, "first", "Unassigned");
assertCell(leaderboard, 0, "second", "1");
assertCell(leaderboard, 0, "third", "72,000");
}
/*******************************************************************************
** A partial submission name uses the email to retain identifying context.
*******************************************************************************/
@Test
void partialLeadName() throws Exception
{
seed("crm_form_submission", new QRecord().withValue("firstName", "Lee").withValue("email", "lee@example.test"));
assertCell((TableData) render("crm_recent_web_leads"), 0, "first", "lee@example.test");
}
/*******************************************************************************
** Empty data renders zero statistics, six months and no goals card.
*******************************************************************************/
@@ -119,16 +167,16 @@ class CrmHomeWidgetsTest extends BaseTest
TableData data = (TableData) render("crm_pipeline_summary");
assertThat(data.getColumns()).hasSize(3);
assertThat(data.getRows()).hasSize(3);
assertCell(data, 0, "first", "Open (2 deals)");
assertCell(data, 0, "second", "300.00");
assertCell(data, 0, "third", "40.50");
assertCell(data, 1, "first", "Won (0 deals)");
assertCell(data, 0, "first", "Open");
assertCell(data, 0, "second", "2");
assertCell(data, 0, "third", "40.5");
assertCell(data, 1, "first", "Won");
assertThat(data.getDropdownNameList()).containsExactly("pipelineId");
RenderWidgetInput selected = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_pipeline_summary"));
selected.addQueryParam("pipelineId", "2");
TableData other = (TableData) new CrmHomeRenderer().render(selected).getWidgetData();
assertThat(other.getRows()).hasSize(1);
assertCell(other, 0, "first", "Other (1 deals)");
assertCell(other, 0, "first", "Other");
selected.addQueryParam("pipelineId", "999");
assertThat(((TableData) new CrmHomeRenderer().render(selected).getWidgetData()).getRows()).hasSize(3);
assertLinks(data);
@@ -223,7 +271,7 @@ class CrmHomeWidgetsTest extends BaseTest
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_goals")).isFalse();
assertThat(data.getRows()).hasSize(1);
assertCell(data, 0, "first", TEST_USER_ID + " (50.0%)");
assertCell(data, 0, "second", "500.50");
assertCell(data, 0, "second", "500.5");
assertCell(data, 0, "third", "250.25");
assertLinks(data);
}
@@ -247,7 +295,7 @@ class CrmHomeWidgetsTest extends BaseTest
assertThat(data.getRows()).hasSize(2);
assertCell(data, 0, "first", "Ada");
assertCell(data, 0, "second", "2");
assertCell(data, 0, "third", "251.00");
assertCell(data, 0, "third", "251");
assertLinks(data);
}
@@ -331,7 +379,7 @@ class CrmHomeWidgetsTest extends BaseTest
*******************************************************************************/
private void assertStatistic(String name, String expected) throws QException
{
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString())).isEqualByComparingTo(expected);
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString().replace(",", ""))).isEqualByComparingTo(expected);
}