Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424f6ea379
|
||
|
|
8d9dfb9172
|
||
|
|
e1fb2c0deb
|
||
|
|
68220ec915
|
||
|
|
666cec834e
|
||
|
|
4b1d272ac5 |
No files matched your search
+16
-4
@@ -1,6 +1,6 @@
|
||||
-- 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
|
||||
@@ -189,7 +189,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 +252,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,9 +364,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 +396,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);
|
||||
@@ -640,3 +645,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
|
||||
);
|
||||
@@ -43,6 +43,9 @@ public class CrmQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
|
||||
|
||||
private String schedulerName;
|
||||
private Boolean schedulesEnabled = true;
|
||||
|
||||
private String messagingProviderName;
|
||||
|
||||
private String publicBaseUrl;
|
||||
@@ -271,4 +274,38 @@ 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,6 +40,8 @@ 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.scoring.AutoAssignCustomizer;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
|
||||
@@ -54,6 +57,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 +67,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;
|
||||
@@ -139,6 +146,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));
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
@@ -171,11 +184,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,6 +352,12 @@ 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);
|
||||
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"));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.deals;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deal age notifications with a persistent once-per-deal-and-stage checkpoint.
|
||||
*******************************************************************************/
|
||||
public final class CrmDealNotifications
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmDealNotifications()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan open deals in keyset pages, resolving stage metadata once per run.
|
||||
*******************************************************************************/
|
||||
public static void checkRotting() throws QException
|
||||
{
|
||||
Map<Integer, QRecord> stages = new HashMap<>();
|
||||
for(QRecord stage : new QueryAction().execute(new QueryInput("crm_pipeline_stage")).getRecords())
|
||||
{
|
||||
stages.put(stage.getValueInteger("id"), stage);
|
||||
}
|
||||
Integer lastId = null;
|
||||
Instant now = Instant.now();
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_deal").withFilter(filter)).getRecords();
|
||||
for(QRecord deal : page)
|
||||
{
|
||||
lastId = deal.getValueInteger("id");
|
||||
QRecord stage = stages.get(deal.getValueInteger("pipelineStageId"));
|
||||
Instant entered = deal.getValueInstant("stageEnteredDate");
|
||||
if(stage == null || entered == null || deal.getValueString("ownerUserId") == null
|
||||
|| Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Integer days = stage.getValueInteger("rotDays");
|
||||
if(days == null || days <= 0 || !entered.isBefore(now.minus(days, ChronoUnit.DAYS)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String key = "deal:" + lastId + ":stage:" + stage.getValue("id");
|
||||
String checkpoint = "crm.deal.rotting:" + key;
|
||||
if(new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, checkpoint)))).getRecords().isEmpty())
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Deal needs attention").withSourceName("crm.deal.rotting").withSourceKey(key)
|
||||
.withSourceTableName("crm_deal").withSourceRecordId(lastId.toString())
|
||||
.withAudience(NotificationAudience.user(deal.getValueString("ownerUserId"))));
|
||||
CrmRecords.insert(JobState.TABLE_NAME, new QRecord().withValue("name", checkpoint).withValue("lastSuccess", now));
|
||||
}
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -51,6 +51,9 @@ import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
@@ -192,6 +195,14 @@ public class DealStageChangeCustomizer extends AbstractPreUpdateCustomizer
|
||||
// insert DealStageHistory row //
|
||||
///////////////////////////////////////////
|
||||
insertStageHistoryRow(record, oldRecord, oldStageId, newStageId, now);
|
||||
String owner = record.getValues().containsKey("ownerUserId") ? record.getValueString("ownerUserId") : oldRecord.getValueString("ownerUserId");
|
||||
if(Boolean.TRUE.equals(newStage.getIsClosedWon()) && owner != null)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Deal won").withSourceName("crm.deal.won")
|
||||
.withSourceKey("deal:" + record.getValue("id") + ":stage:" + newStageId + ":" + now)
|
||||
.withSourceTableName(Deal.TABLE_NAME).withSourceRecordId(record.getValueString("id"))
|
||||
.withAudience(NotificationAudience.user(owner)), updateInput == null ? null : updateInput.getTransaction());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ public class Deal extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer primaryContactId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
@QField()
|
||||
private String ownerUserId;
|
||||
|
||||
@QField(possibleValueSourceName = LeadSource.TABLE_NAME)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Carry the core process UUID across Review/Result and retries in process state.
|
||||
*******************************************************************************/
|
||||
public class CrmEmailProcessIdentity implements ProcessLifecycleListenerInterface
|
||||
{
|
||||
@Override
|
||||
public boolean appliesTo(String processName)
|
||||
{
|
||||
return ("crm_send_email".equals(processName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProcessStarted(RunProcessInput input)
|
||||
{
|
||||
input.getProcessState().getValues().put("crmEmailProcessUUID", input.getProcessUUID());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProcessCompleted(RunProcessInput input, RunProcessOutput output)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProcessFailed(RunProcessInput input, Exception exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,11 @@
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiEffectOutcome;
|
||||
@@ -87,6 +90,14 @@ public class CrmEmailTransport
|
||||
** Deliver one message, using a stable CRM purpose/id key for the durable email outbox.
|
||||
*******************************************************************************/
|
||||
public void send(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
deliver(purpose, id, contactId, address, subject, body, marketing);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return the transport result without guessing that queued email was sent.
|
||||
*******************************************************************************/
|
||||
private Delivery deliver(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
if(emailInstalled())
|
||||
{
|
||||
@@ -98,7 +109,9 @@ public class CrmEmailTransport
|
||||
{
|
||||
throw (new QUserFacingException("The email service could not accept this message."));
|
||||
}
|
||||
return;
|
||||
var message = result.message();
|
||||
return (message == null ? new Delivery(null, CrmEmailStatus.QUEUED, null, null, null, null, null)
|
||||
: new Delivery(String.valueOf(message.id()), status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null));
|
||||
}
|
||||
if(readiness() != CrmReadiness.CORE_MESSAGING)
|
||||
{
|
||||
@@ -107,5 +120,52 @@ public class CrmEmailTransport
|
||||
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName(config.getMessagingProviderName())
|
||||
.withTo(new Party().withAddress(address).withRole(EmailPartyRole.TO)).withSubject(subject)
|
||||
.withContent(new Content().withBody(body).withContentRole(EmailContentRole.TEXT)));
|
||||
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM-owned delivery snapshot; optional email types never escape this adapter.
|
||||
*******************************************************************************/
|
||||
public record Delivery(String messageId, CrmEmailStatus status, Instant sentDate, Instant deliveredDate,
|
||||
Instant openedDate, Instant clickedDate, Instant bouncedDate)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send and return the actual outbox state, or the successful core send state.
|
||||
*******************************************************************************/
|
||||
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
send(purpose, id, contactId, address, subject, body, marketing);
|
||||
return (lookup("crm:" + purpose + ":" + id).orElseGet(() ->
|
||||
new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read delivery through the published API only. RC.3 exposes sent/delivered dates.
|
||||
*******************************************************************************/
|
||||
public Optional<Delivery> lookup(String key) throws QException
|
||||
{
|
||||
if(!emailInstalled() || key == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
return (EmailApi.getMessageByIdempotencyKey(key).map(message -> new Delivery(String.valueOf(message.id()),
|
||||
status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve known delivery states and leave unknown/pending states eligible for sync.
|
||||
*******************************************************************************/
|
||||
private CrmEmailStatus status(String status)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (CrmEmailStatus.valueOf(status));
|
||||
}
|
||||
catch(IllegalArgumentException | NullPointerException e)
|
||||
{
|
||||
return (CrmEmailStatus.QUEUED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.api.ActivityKind;
|
||||
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared CRM template rendering, transport delivery and timeline recording.
|
||||
*******************************************************************************/
|
||||
public final class CrmEmails
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmEmails()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A skip reason suitable for standard WARNING summary lines.
|
||||
*******************************************************************************/
|
||||
public static String skipReason(QRecord contact, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(contact.getValueBoolean("doNotEmail")))
|
||||
{
|
||||
return ("Contact has opted out of email");
|
||||
}
|
||||
if(contact.getValueString("email") == null || contact.getValueString("email").isBlank())
|
||||
{
|
||||
return ("Contact has no email address");
|
||||
}
|
||||
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED ? "CRM email is not configured" : null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send once using the caller's stable purpose/id and record the transport outcome.
|
||||
*******************************************************************************/
|
||||
public static void send(CrmEmailTransport transport, QRecord contact, Integer templateId, String subject, String body,
|
||||
String purpose, String id, Integer enrollmentId) throws QException
|
||||
{
|
||||
String key = "crm:" + purpose + ":" + id;
|
||||
List<QRecord> existing = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(
|
||||
new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.EQUALS, key)))).getRecords();
|
||||
if(!existing.isEmpty())
|
||||
{
|
||||
recordActivity(existing.getFirst());
|
||||
return;
|
||||
}
|
||||
if(templateId != null)
|
||||
{
|
||||
QRecord template = GetAction.execute("crm_email_template", templateId);
|
||||
if(template == null)
|
||||
{
|
||||
throw (new QUserFacingException("Email template is unavailable"));
|
||||
}
|
||||
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
|
||||
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
|
||||
if(body == null)
|
||||
{
|
||||
body = template.getValueString("bodyHtml");
|
||||
}
|
||||
}
|
||||
if(subject == null || subject.isBlank() || body == null || body.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Email subject and body are required"));
|
||||
}
|
||||
subject = render(subject, contact);
|
||||
body = render(body, contact);
|
||||
CrmEmailTransport.Delivery delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
|
||||
contact.getValueString("email"), subject, body, enrollmentId != null);
|
||||
QRecord message = CrmRecords.insert("crm_email_message", new QRecord().withValue("contactId", contact.getValue("id"))
|
||||
.withValue("emailTemplateId", templateId).withValue("sequenceEnrollmentId", enrollmentId)
|
||||
.withValue("toAddresses", contact.getValue("email")).withValue("subject", subject)
|
||||
.withValue("direction", CrmDirection.OUTBOUND.getId()).withValue("status", delivery.status().getId())
|
||||
.withValue("idempotencyKey", key).withValue("emailMessageId", delivery.messageId())
|
||||
.withValue("sentDate", delivery.sentDate()).withValue("deliveredDate", delivery.deliveredDate()));
|
||||
recordActivity(message);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Repair a partially recorded send without sending again or duplicating its activity.
|
||||
*******************************************************************************/
|
||||
private static void recordActivity(QRecord message) throws QException
|
||||
{
|
||||
if(message.getValue("activityId") != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String description = "Email: " + message.getValueString("idempotencyKey");
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, message.getValue("contactId")),
|
||||
new QFilterCriteria("description", QCriteriaOperator.EQUALS, description));
|
||||
List<QRecord> activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
|
||||
if(activities.isEmpty())
|
||||
{
|
||||
new CrmMarketingApi().recordActivity(message.getValueInteger("contactId"),
|
||||
message.getValue("sequenceEnrollmentId") == null ? ActivityKind.EMAIL : ActivityKind.SEQUENCE_STEP,
|
||||
message.getValueString("subject"), description);
|
||||
activities = new QueryAction().execute(new QueryInput("crm_activity").withFilter(filter)).getRecords();
|
||||
}
|
||||
CrmRecords.update("crm_email_message", new QRecord().withValue("id", message.getValue("id"))
|
||||
.withValue("activityId", activities.getFirst().getValue("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Literal replacements avoid treating customer data as a template program.
|
||||
*******************************************************************************/
|
||||
public static String render(String text, QRecord contact) throws QException
|
||||
{
|
||||
QRecord company = contact.getValue("companyId") == null ? null : GetAction.execute("crm_company", contact.getValue("companyId"));
|
||||
Map<String, String> fields = Map.of("contact.firstName", value(contact, "firstName"), "contact.lastName", value(contact, "lastName"),
|
||||
"company.name", value(company, "name"), "owner.name", value(contact, "ownerUserId"));
|
||||
// Replace each token in the original text only, so inserted values cannot introduce merge fields.
|
||||
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}").matcher(text);
|
||||
StringBuilder rendered = new StringBuilder();
|
||||
while(matcher.find())
|
||||
{
|
||||
String replacement = "unsubscribeUrl".equals(matcher.group(1))
|
||||
? new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null) : fields.get(matcher.group(1));
|
||||
matcher.appendReplacement(rendered, java.util.regex.Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(rendered);
|
||||
return (rendered.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing merge values render as empty strings.
|
||||
*******************************************************************************/
|
||||
private static String value(QRecord row, String field)
|
||||
{
|
||||
return (row == null || row.getValueString(field) == null ? "" : row.getValueString(field));
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -75,6 +76,7 @@ public class EmailMessage extends QRecordEntity
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
table
|
||||
.withUniqueKey(new UniqueKey("idempotencyKey"))
|
||||
.withIcon(new QIcon().withName("email"))
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("subject")
|
||||
@@ -85,10 +87,10 @@ public class EmailMessage extends QRecordEntity
|
||||
List.of("bodyHtml", "bodyText")))
|
||||
.withSection(new QFieldSection("tracking", new QIcon("visibility"), Tier.T2,
|
||||
List.of("isTracked", "openCount", "clickCount", "firstOpenedDate", "firstClickedDate",
|
||||
"bounceType", "isSpamComplaint")))
|
||||
"bounceType", "isSpamComplaint", "deliveredDate", "bouncedDate")))
|
||||
.withSection(new QFieldSection("references", new QIcon("link"), Tier.T2,
|
||||
List.of("contactId", "activityId", "emailTemplateId", "sequenceEnrollmentId",
|
||||
"threadId", "replyToEmailId")))
|
||||
"threadId", "replyToEmailId", "idempotencyKey", "emailMessageId")))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
return (table);
|
||||
@@ -106,7 +108,7 @@ public class EmailMessage extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer contactId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String fromAddress;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@@ -172,6 +174,18 @@ public class EmailMessage extends QRecordEntity
|
||||
@QField()
|
||||
private Integer replyToEmailId;
|
||||
|
||||
@QField(maxLength = 250)
|
||||
private String idempotencyKey;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String emailMessageId;
|
||||
|
||||
@QField()
|
||||
private Instant deliveredDate;
|
||||
|
||||
@QField()
|
||||
private Instant bouncedDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -1031,4 +1045,103 @@ public class EmailMessage extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return (idempotencyKey);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get emailMessageId.
|
||||
*******************************************************************************/
|
||||
public String getEmailMessageId()
|
||||
{
|
||||
return (emailMessageId);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set emailMessageId.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withEmailMessageId(String emailMessageId)
|
||||
{
|
||||
this.emailMessageId = emailMessageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get deliveredDate.
|
||||
*******************************************************************************/
|
||||
public Instant getDeliveredDate()
|
||||
{
|
||||
return (deliveredDate);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveredDate.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withDeliveredDate(Instant deliveredDate)
|
||||
{
|
||||
this.deliveredDate = deliveredDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get bouncedDate.
|
||||
*******************************************************************************/
|
||||
public Instant getBouncedDate()
|
||||
{
|
||||
return (bouncedDate);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set bouncedDate.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withBouncedDate(Instant bouncedDate)
|
||||
{
|
||||
this.bouncedDate = bouncedDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set idempotencyKey for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set emailMessageId for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setEmailMessageId(String emailMessageId)
|
||||
{
|
||||
this.emailMessageId = emailMessageId;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveredDate for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setDeliveredDate(Instant deliveredDate)
|
||||
{
|
||||
this.deliveredDate = deliveredDate;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set bouncedDate for entity mapping.
|
||||
*******************************************************************************/
|
||||
public void setBouncedDate(Instant bouncedDate)
|
||||
{
|
||||
this.bouncedDate = bouncedDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled CRM maintenance: crm_expire_consents.
|
||||
*******************************************************************************/
|
||||
public class ExpireConsentsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_expire_consents";
|
||||
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance instance)
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withLabel("ExpireConsents").withIsHidden(true)
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(ExpireConsentsProcess.class))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
Instant cutoff = now.minus(14, ChronoUnit.DAYS);
|
||||
Integer lastId = null;
|
||||
int expired = 0;
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmListMemberStatus.PENDING_CONFIRMATION.getId()))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_list_member").withFilter(filter)).getRecords();
|
||||
for(QRecord member : page)
|
||||
{
|
||||
lastId = member.getValueInteger("id");
|
||||
Instant pendingSince = member.getValueInstant("confirmationSentDate");
|
||||
if(pendingSince == null)
|
||||
{
|
||||
pendingSince = member.getValueInstant("createDate");
|
||||
}
|
||||
if(pendingSince != null && !pendingSince.isAfter(cutoff))
|
||||
{
|
||||
CrmRecords.update("crm_email_list_member", new QRecord().withValue("id", lastId)
|
||||
.withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()).withValue("confirmationTokenHash", null)
|
||||
.withValue("unsubscribedDate", now));
|
||||
expired++;
|
||||
}
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
output.addValue("expiredPendingCount", expired);
|
||||
output.addValue("expiredConsentCount", new CountAction().execute(new CountInput("crm_consent_record")
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("expiryDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)))).getCount());
|
||||
}
|
||||
}
|
||||
+59
-48
@@ -34,9 +34,15 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmails;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailSequence;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceStep;
|
||||
@@ -45,6 +51,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
@@ -79,6 +87,15 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
|
||||
private static final int MAX_FAILURES = 3;
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the host transport; tests may supply a fake.
|
||||
*******************************************************************************/
|
||||
protected CrmEmailTransport emailTransport() throws QException
|
||||
{
|
||||
return (new CrmEmailTransport(CrmApi.config()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -92,7 +109,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
.withIsHidden(true)
|
||||
.withLabel("Process Sequence Steps")
|
||||
.withIcon(new QIcon().withName("play_circle"))
|
||||
// Scheduling is configured by the host application via ScheduledJob records
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
@@ -111,6 +127,18 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
///////////////////////////////////////////////////////////
|
||||
// query enrollments where status=ACTIVE and due now //
|
||||
///////////////////////////////////////////////////////////
|
||||
CrmEmailTransport transport = emailTransport();
|
||||
boolean ready = transport.readiness() != CrmReadiness.NOT_CONFIGURED;
|
||||
boolean activeSequences = !new QueryAction().execute(new QueryInput(EmailSequence.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withLimit(1))).getRecords().isEmpty();
|
||||
if(!ready && activeSequences)
|
||||
{
|
||||
QAlerts.raise(new AlertSignal("crm.email.unavailable").withDedupKey("transport").withTitle("CRM email is unavailable"));
|
||||
}
|
||||
else if(ready)
|
||||
{
|
||||
QAlerts.resolve("crm.email.unavailable", "transport", "Email transport is ready");
|
||||
}
|
||||
int processedCount = 0;
|
||||
Integer lastId = null;
|
||||
Instant dueThrough = Instant.now();
|
||||
@@ -130,7 +158,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
|
||||
try
|
||||
{
|
||||
processEnrollment(enrollment);
|
||||
processEnrollment(enrollment, transport);
|
||||
processedCount++;
|
||||
}
|
||||
catch(Exception e)
|
||||
@@ -154,7 +182,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
** Process a single enrollment: load the next step and execute it.
|
||||
** Respects businessDaysOnly and sendWindow settings on the sequence.
|
||||
***************************************************************************/
|
||||
private void processEnrollment(SequenceEnrollment enrollment) throws QException
|
||||
private void processEnrollment(SequenceEnrollment enrollment, CrmEmailTransport transport) throws QException
|
||||
{
|
||||
Integer nextStepNumber = enrollment.getCurrentStepNumber() + 1;
|
||||
|
||||
@@ -167,6 +195,11 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
EmailSequence sequence = sequenceGet.getRecord() != null
|
||||
? new EmailSequence(sequenceGet.getRecord()) : null;
|
||||
|
||||
if(sequence == null || !Boolean.TRUE.equals(sequence.getIsActive()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// load the contact for timezone //
|
||||
/////////////////////////////////////////////
|
||||
@@ -237,7 +270,27 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
{
|
||||
switch(stepType)
|
||||
{
|
||||
case EMAIL -> executeEmailStep(enrollment, step);
|
||||
case EMAIL ->
|
||||
{
|
||||
var eligibility = new CrmMarketingApi().checkEmailEligibility(enrollment.getContactId());
|
||||
if(!eligibility.eligible())
|
||||
{
|
||||
CrmRecords.update(SequenceEnrollment.TABLE_NAME, new QRecord().withValue("id", enrollment.getId())
|
||||
.withValue("status", CrmEnrollmentStatus.PAUSED.getId()).withValue("lastFailureMessage", eligibility.reason())
|
||||
.withValue("nextStepDate", null));
|
||||
return;
|
||||
}
|
||||
if(transport.readiness() == CrmReadiness.NOT_CONFIGURED)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(step.getEmailTemplateId() == null)
|
||||
{
|
||||
throw (new QException("Sequence email template is required"));
|
||||
}
|
||||
CrmEmails.send(transport, contactGet.getRecord(), step.getEmailTemplateId(), null, null,
|
||||
"seq", enrollment.getId() + ":" + step.getId(), enrollment.getId());
|
||||
}
|
||||
case CALL -> executeCallStep(enrollment, step);
|
||||
case TASK -> executeTaskStep(enrollment, step);
|
||||
case WAIT ->
|
||||
@@ -257,50 +310,6 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Execute an EMAIL step: verify doNotEmail, create an activity record.
|
||||
***************************************************************************/
|
||||
private void executeEmailStep(SequenceEnrollment enrollment, SequenceStep step) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// check doNotEmail //
|
||||
///////////////////////////////////////////////
|
||||
GetOutput contactGet = new GetAction().execute(
|
||||
new GetInput(Contact.TABLE_NAME).withPrimaryKey(enrollment.getContactId()));
|
||||
|
||||
if(contactGet.getRecord() != null)
|
||||
{
|
||||
Contact contact = new Contact(contactGet.getRecord());
|
||||
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
|
||||
{
|
||||
throw (new QException("Contact has opted out of email"));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// create activity for the email //
|
||||
///////////////////////////////////////////////
|
||||
String subject = "Sequence email: step " + step.getStepNumber();
|
||||
if(step.getEmailTemplateId() != null)
|
||||
{
|
||||
subject += " (template #" + step.getEmailTemplateId() + ")";
|
||||
}
|
||||
|
||||
Integer activityTypeId = lookupActivityTypeByName("Email");
|
||||
|
||||
new InsertAction().execute(
|
||||
new InsertInput(Activity.TABLE_NAME).withRecordEntity(
|
||||
new Activity()
|
||||
.withActivityTypeId(activityTypeId)
|
||||
.withContactId(enrollment.getContactId())
|
||||
.withDealId(enrollment.getDealId())
|
||||
.withSubject(subject)
|
||||
.withOwnerUserId("system")
|
||||
.withIsCompleted(true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Execute a CALL step: create a task activity assigned to the contact owner.
|
||||
***************************************************************************/
|
||||
@@ -524,6 +533,8 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
{
|
||||
updateRecord.withValue("status", CrmEnrollmentStatus.FAILED.getPossibleValueId());
|
||||
updateRecord.withValue("nextStepDate", null);
|
||||
QAlerts.raise(new AlertSignal("crm.sequence.failing").withDedupKey("enrollment:" + enrollment.getId())
|
||||
.withTitle("Sequence enrollment failed three times"));
|
||||
}
|
||||
|
||||
new UpdateAction().execute(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled CRM maintenance: crm_sync_email_delivery.
|
||||
*******************************************************************************/
|
||||
public class SyncEmailDeliveryProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_sync_email_delivery";
|
||||
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance instance)
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withLabel("SyncEmailDelivery").withIsHidden(true)
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(SyncEmailDeliveryProcess.class))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the host transport; an absent qbit-email yields empty lookups.
|
||||
*******************************************************************************/
|
||||
protected CrmEmailTransport emailTransport() throws QException
|
||||
{
|
||||
return (new CrmEmailTransport(CrmApi.config()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmEmailTransport transport = emailTransport();
|
||||
Integer lastId = null;
|
||||
int updated = 0;
|
||||
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput("crm_email_message").withFilter(filter)).getRecords();
|
||||
for(QRecord message : page)
|
||||
{
|
||||
lastId = message.getValueInteger("id");
|
||||
if(message.getValueInteger("status") != null && List.of(CrmEmailStatus.BOUNCED.getId(), CrmEmailStatus.COMPLAINED.getId(), CrmEmailStatus.FAILED.getId(),
|
||||
CrmEmailStatus.SUPPRESSED.getId()).contains(message.getValueInteger("status")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var delivery = transport.lookup(message.getValueString("idempotencyKey"));
|
||||
if(delivery.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
CrmEmailTransport.Delivery state = delivery.get();
|
||||
QRecord change = new QRecord().withValue("id", lastId).withValue("status", state.status().getId());
|
||||
date(change, "sentDate", state.sentDate());
|
||||
date(change, "deliveredDate", state.deliveredDate());
|
||||
date(change, "firstOpenedDate", state.openedDate());
|
||||
date(change, "firstClickedDate", state.clickedDate());
|
||||
date(change, "bouncedDate", state.bouncedDate());
|
||||
boolean complaint = state.status() == CrmEmailStatus.COMPLAINED;
|
||||
if(complaint || state.status() == CrmEmailStatus.BOUNCED)
|
||||
{
|
||||
Integer contactId = message.getValueInteger("contactId");
|
||||
if(contactId != null)
|
||||
{
|
||||
CrmRecords.update("crm_contact", new QRecord().withValue("id", contactId).withValue("doNotEmail", true));
|
||||
String source = complaint ? "Complaint" : "Hard bounce";
|
||||
// The terminal message checkpoint is written last. A retry must not duplicate consent evidence.
|
||||
String evidence = "Email: " + message.getValueString("idempotencyKey");
|
||||
List<QRecord> recorded = new QueryAction().execute(new QueryInput("crm_consent_record").withFilter(new QQueryFilter(
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId),
|
||||
new QFilterCriteria("source", QCriteriaOperator.EQUALS, source),
|
||||
new QFilterCriteria("consentText", QCriteriaOperator.EQUALS, evidence)))).getRecords();
|
||||
if(recorded.isEmpty())
|
||||
{
|
||||
CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId)
|
||||
.withValue("consentType", CrmConsentType.EMAIL.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId())
|
||||
.withValue("consentDate", Instant.now()).withValue("source", source).withValue("consentText", evidence));
|
||||
}
|
||||
}
|
||||
change.withValue("isSpamComplaint", complaint);
|
||||
}
|
||||
CrmRecords.update("crm_email_message", change);
|
||||
updated++;
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
output.addValue("updatedCount", updated);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never clear a timestamp when the provider has not exposed it.
|
||||
*******************************************************************************/
|
||||
private void date(QRecord row, String field, Instant value)
|
||||
{
|
||||
if(value != null)
|
||||
{
|
||||
row.setValue(field, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.scoring;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply assignment rules to new contacts and deals without replacing explicit owners.
|
||||
*******************************************************************************/
|
||||
public class AutoAssignCustomizer extends AbstractPostInsertCustomizer
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records) throws QException
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(row.getErrors() != null && !row.getErrors().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String owner = row.getValueString("ownerUserId");
|
||||
if(owner == null || "system".equals(owner))
|
||||
{
|
||||
CrmAutoAssign.Result result = CrmAutoAssign.assign("crm_contact".equals(insertInput.getTableName())
|
||||
? CrmAssignableEntity.CONTACT : CrmAssignableEntity.DEAL, row.getValueInteger("id"));
|
||||
if("ASSIGNED".equals(result.action()))
|
||||
{
|
||||
row.setValue("ownerUserId", result.assignedUserId());
|
||||
QAlerts.notify(new NotificationSignal("CRM lead assigned").withSourceName("crm.lead.assigned")
|
||||
.withSourceKey(insertInput.getTableName() + ":" + row.getValue("id"))
|
||||
.withSourceTableName(insertInput.getTableName()).withSourceRecordId(row.getValueString("id"))
|
||||
.withAudience(NotificationAudience.user(result.assignedUserId())), insertInput.getTransaction());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.scoring;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small persistent maintenance checkpoints, including the last full score pass.
|
||||
*******************************************************************************/
|
||||
public class JobState implements MetaDataProducerInterface<QTableMetaData>
|
||||
{
|
||||
public static final String TABLE_NAME = "crm_job_state";
|
||||
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance instance)
|
||||
{
|
||||
return (new QTableMetaData().withName(TABLE_NAME).withLabel("Job State").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true).withMaxLength(250))
|
||||
.withField(new QFieldMetaData("lastSuccess", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("lastFullPass", QFieldType.DATE_TIME))
|
||||
.withUniqueKey(new UniqueKey("name")));
|
||||
}
|
||||
}
|
||||
+115
-50
@@ -26,22 +26,29 @@
|
||||
package com.kingsrook.qbits.crm.scoring.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
|
||||
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
|
||||
import com.kingsrook.qbits.crm.scoring.JobState;
|
||||
import com.kingsrook.qbits.crm.scoring.model.LeadScoreRule;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -83,7 +90,6 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
.withIsHidden(true)
|
||||
.withLabel("Calculate Lead Scores")
|
||||
.withIcon(new QIcon().withName("score"))
|
||||
// Scheduling is configured by the host application via ScheduledJob records
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
@@ -99,6 +105,14 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Instant startedAt = Instant.now();
|
||||
List<QRecord> states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords();
|
||||
QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst();
|
||||
Instant lastSuccess = state.getValueInstant("lastSuccess");
|
||||
Instant lastFullPass = state.getValueInstant("lastFullPass");
|
||||
boolean full = Boolean.TRUE.equals(input.getValueBoolean("fullRecalc")) || lastSuccess == null || lastFullPass == null
|
||||
|| !lastFullPass.isAfter(startedAt.minus(24, ChronoUnit.HOURS));
|
||||
///////////////////////////////////////////////
|
||||
// load all active rules ordered by sort //
|
||||
///////////////////////////////////////////////
|
||||
@@ -131,6 +145,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
|
||||
if(!full)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("modifyDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, lastSuccess));
|
||||
}
|
||||
QueryOutput contactsQuery = new QueryAction().execute(
|
||||
new QueryInput(Contact.TABLE_NAME).withFilter(filter));
|
||||
|
||||
@@ -139,15 +157,20 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
break;
|
||||
}
|
||||
|
||||
Map<Integer, QRecord> companies = loadCompanies(contactsQuery.getRecords());
|
||||
List<QRecord> changes = new ArrayList<>();
|
||||
for(QRecord contactRecord : contactsQuery.getRecords())
|
||||
{
|
||||
totalContacts++;
|
||||
if(recalculate(contactRecord, rules))
|
||||
QRecord change = calculateChange(contactRecord, rules, companies);
|
||||
if(change != null)
|
||||
{
|
||||
updatedCount++;
|
||||
changes.add(change);
|
||||
}
|
||||
lastId = contactRecord.getValueInteger("id");
|
||||
}
|
||||
writeScores(changes);
|
||||
updatedCount += changes.size();
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// if page was not full, we are done //
|
||||
@@ -158,6 +181,21 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
}
|
||||
}
|
||||
|
||||
CrmDealNotifications.checkRotting();
|
||||
state.withValue("lastSuccess", startedAt);
|
||||
if(full)
|
||||
{
|
||||
state.withValue("lastFullPass", startedAt);
|
||||
}
|
||||
if(states.isEmpty())
|
||||
{
|
||||
CrmRecords.insert(JobState.TABLE_NAME, state);
|
||||
}
|
||||
else
|
||||
{
|
||||
CrmRecords.update(JobState.TABLE_NAME, state);
|
||||
}
|
||||
output.addValue("fullRecalc", full);
|
||||
output.addValue("updatedCount", updatedCount);
|
||||
output.addValue("totalContacts", totalContacts);
|
||||
output.addValue("totalRules", rules.size());
|
||||
@@ -173,18 +211,19 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
List<LeadScoreRule> rules = new QueryAction().execute(new QueryInput(LeadScoreRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")))).getRecords().stream().map(LeadScoreRule::new).toList();
|
||||
recalculate(contact, rules);
|
||||
QRecord change = calculateChange(contact, rules, loadCompanies(List.of(contact)));
|
||||
writeScores(change == null ? List.of() : List.of(change));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Write only changed scores; return whether a write was needed.
|
||||
** Compute a score update only when the result changed.
|
||||
*******************************************************************************/
|
||||
private boolean recalculate(QRecord contact, List<LeadScoreRule> rules) throws QException
|
||||
private QRecord calculateChange(QRecord contact, List<LeadScoreRule> rules, Map<Integer, QRecord> companies) throws QException
|
||||
{
|
||||
int score = 0;
|
||||
for(LeadScoreRule rule : rules)
|
||||
{
|
||||
if(evaluateRule(rule, contact))
|
||||
if(evaluateRule(rule, contact, companies))
|
||||
{
|
||||
score += rule.getScoreAdjustment();
|
||||
}
|
||||
@@ -192,11 +231,27 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
Integer current = contact.getValueInteger("leadScore");
|
||||
if(Objects.equals(current == null ? 0 : current, score))
|
||||
{
|
||||
return (false);
|
||||
return (null);
|
||||
}
|
||||
return (new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Batch score writes so core audit label resolution also operates per page.
|
||||
*******************************************************************************/
|
||||
private void writeScores(List<QRecord> changes) throws QException
|
||||
{
|
||||
if(changes.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
UpdateInput update = new UpdateInput(Contact.TABLE_NAME).withOmitModifyDateUpdate(true);
|
||||
update.setRecords(changes);
|
||||
var updated = new UpdateAction().execute(update).getRecords();
|
||||
if(updated.isEmpty() || updated.stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QException("Lead score update was rejected"));
|
||||
}
|
||||
new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score)));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +259,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
** Evaluate a single rule against a contact record. Supports dot-notation
|
||||
** field paths for related entities (e.g., "company.industry").
|
||||
***************************************************************************/
|
||||
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord)
|
||||
private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord, Map<Integer, QRecord> companies)
|
||||
{
|
||||
CrmScoreOperator operator = CrmScoreOperator.getById(rule.getOperator());
|
||||
if(operator == null)
|
||||
@@ -213,7 +268,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
}
|
||||
|
||||
String fieldPath = rule.getFieldPath();
|
||||
String actualValue = resolveFieldValue(fieldPath, contactRecord);
|
||||
String actualValue = resolveFieldValue(fieldPath, contactRecord, companies);
|
||||
String expectedValue = rule.getFieldValue();
|
||||
|
||||
switch(operator)
|
||||
@@ -233,6 +288,10 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
case LT:
|
||||
return (compareNumeric(actualValue, expectedValue) < 0);
|
||||
|
||||
case DAYS_AGO_GT:
|
||||
case DAYS_AGO_LT:
|
||||
return (compareAge(actualValue, expectedValue, operator));
|
||||
|
||||
case IS_SET:
|
||||
return (StringUtils.hasContent(actualValue));
|
||||
|
||||
@@ -272,48 +331,54 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
** for related entity fields (e.g., "company.industry" loads the contact's
|
||||
** company record and returns its "industry" field).
|
||||
***************************************************************************/
|
||||
private String resolveFieldValue(String fieldPath, QRecord contactRecord)
|
||||
private String resolveFieldValue(String fieldPath, QRecord contactRecord, Map<Integer, QRecord> companies)
|
||||
{
|
||||
if(fieldPath == null || !fieldPath.contains("."))
|
||||
{
|
||||
return (contactRecord.getValueString(fieldPath));
|
||||
}
|
||||
|
||||
String[] parts = fieldPath.split("\\.", 2);
|
||||
String prefix = parts[0];
|
||||
String fieldName = parts[1];
|
||||
|
||||
try
|
||||
if("company".equalsIgnoreCase(parts[0]))
|
||||
{
|
||||
if("company".equalsIgnoreCase(prefix))
|
||||
{
|
||||
Integer companyId = contactRecord.getValueInteger("companyId");
|
||||
if(companyId == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
GetOutput companyOutput = new GetAction().execute(
|
||||
new GetInput(Company.TABLE_NAME).withPrimaryKey(companyId));
|
||||
|
||||
if(companyOutput.getRecord() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (companyOutput.getRecord().getValueString(fieldName));
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + prefix);
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error resolving dot-notation field [" + fieldPath + "] for contact", e);
|
||||
return (null);
|
||||
QRecord company = companies.get(contactRecord.getValueInteger("companyId"));
|
||||
return (company == null ? null : company.getValueString(parts[1]));
|
||||
}
|
||||
LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + parts[0]);
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One company query per page, shared by every rule and contact in that page.
|
||||
*******************************************************************************/
|
||||
private Map<Integer, QRecord> loadCompanies(List<QRecord> contacts) throws QException
|
||||
{
|
||||
List<Integer> ids = contacts.stream().map(row -> row.getValueInteger("companyId")).filter(Objects::nonNull).distinct().toList();
|
||||
Map<Integer, QRecord> companies = new HashMap<>();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord company : new QueryAction().execute(new QueryInput(Company.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)))).getRecords())
|
||||
{
|
||||
companies.put(company.getValueInteger("id"), company);
|
||||
}
|
||||
}
|
||||
return (companies);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare instants or UTC calendar dates with the strict N-days-ago boundary.
|
||||
*******************************************************************************/
|
||||
private boolean compareAge(String actual, String expected, CrmScoreOperator operator)
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant date = actual.length() == 10 ? LocalDate.parse(actual).atStartOfDay(ZoneOffset.UTC).toInstant() : Instant.parse(actual);
|
||||
Instant threshold = Instant.now().minus(Long.parseLong(expected), ChronoUnit.DAYS);
|
||||
return (operator == CrmScoreOperator.DAYS_AGO_GT ? date.isBefore(threshold) : date.isAfter(threshold));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
--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
|
||||
@@ -199,7 +199,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 +265,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,
|
||||
@@ -383,9 +383,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 +415,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);
|
||||
@@ -674,3 +679,11 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
|
||||
--changeset qbit-crm:crm_job_state
|
||||
CREATE TABLE IF NOT EXISTS crm_job_state (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL UNIQUE,
|
||||
last_success TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_full_pass TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
@@ -90,7 +90,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ class CrmQBitProducerTest extends BaseTest
|
||||
{
|
||||
assertThat(QContext.getQInstance().getTable(tableName).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
}
|
||||
assertThat(QContext.getQInstance().getTables()).hasSize(33)
|
||||
assertThat(QContext.getQInstance().getTables()).hasSize(34)
|
||||
.doesNotContainKeys("crm_tag", "crm_contact_tag", "crm_company_tag", "crm_deal_tag", "crm_attachment", "crm_audit_log");
|
||||
assertThat(QContext.getQInstance().getProcesses()).doesNotContainKeys("crm_bulk_add_tag", "crm_bulk_remove_tag");
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ class CrmRdbmsTest
|
||||
@Test
|
||||
void testSchemaMatchesMetadata() throws Exception
|
||||
{
|
||||
assertThat(qInstance.getTables()).hasSize(33);
|
||||
assertThat(qInstance.getTables()).hasSize(34);
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
Set<String> columns = new HashSet<>();
|
||||
@@ -278,7 +278,7 @@ class CrmRdbmsTest
|
||||
String ddl = Files.readString(Path.of("docs/ddl/postgres.sql"));
|
||||
String changelog = Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql"));
|
||||
assertThat(changelog).startsWith("--liquibase formatted sql");
|
||||
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(33);
|
||||
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(34);
|
||||
assertThat(stripComments(changelog)).isEqualTo(stripComments(ddl));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.crm.actions.CrmAction;
|
||||
import com.kingsrook.qbits.crm.actions.CrmActions;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.crm.core.CrmRecords;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType;
|
||||
import com.kingsrook.qbits.crm.deals.CrmDealNotifications;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmails;
|
||||
import com.kingsrook.qbits.crm.email.processes.ExpireConsentsProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.ProcessSequenceStepsProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.SyncEmailDeliveryProcess;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** P4 contracts exercised through memory DML, fake transport and real QAlerts dispatch.
|
||||
*******************************************************************************/
|
||||
class PlatformReuseTest extends BaseTest
|
||||
{
|
||||
private FakeTransport transport;
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep effects local to this test.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void configure() throws Exception
|
||||
{
|
||||
transport = new FakeTransport();
|
||||
CrmApi.config().withPublicBaseUrl("https://crm.example.test").withTokenSecret("test-only-signing-key");
|
||||
Sink.notifications.clear();
|
||||
Sink.alerts.clear();
|
||||
Sink.resolutions.clear();
|
||||
AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance()).withSink(new QCodeReference(Sink.class));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send action renders and records transactional delivery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendActionRendersAndRecordsTransactionalDelivery() throws Exception
|
||||
{
|
||||
QRecord contact = contact(1).withValue("companyId", 1);
|
||||
put("crm_company", new QRecord().withValue("id", 1).withValue("name", "Example"));
|
||||
put("crm_contact", contact);
|
||||
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "Hello {{contact.firstName}}")
|
||||
.withValue("bodyText", "{{contact.lastName}} at {{company.name}} / {{owner.name}} / {{unsubscribeUrl}}"));
|
||||
CrmAction.Load load = load();
|
||||
RunBackendStepInput input = new RunBackendStepInput().withProcessName("crm_send_email").withRecords(List.of(contact))
|
||||
.addValue("emailTemplateId", 1).addValue("crmEmailProcessUUID", "run-123");
|
||||
load.runOnePage(input, new RunBackendStepOutput());
|
||||
assertThat(transport.key).isEqualTo("crm:email:1:run-123");
|
||||
assertThat(transport.marketing).isFalse();
|
||||
assertThat(transport.subject).isEqualTo("Hello Alice");
|
||||
assertThat(transport.body).startsWith("Smith at Example / owner / https://crm.example.test/qqq/hooks/crm/unsubscribe/");
|
||||
assertThat(rows("crm_email_message")).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueInteger("status")).isEqualTo(CrmEmailStatus.QUEUED.getId());
|
||||
assertThat(row.getValueString("emailMessageId")).isEqualTo("provider-1");
|
||||
assertThat(row.getValueString("idempotencyKey")).isEqualTo(transport.key);
|
||||
});
|
||||
assertActivity("EMAIL");
|
||||
load.runOnePage(input, new RunBackendStepOutput());
|
||||
assertThat(transport.sends).isEqualTo(1);
|
||||
assertThat(rows("crm_activity")).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Overrides and missing templates and literal merge values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void overridesAndMissingTemplatesAndLiteralMergeValues() throws Exception
|
||||
{
|
||||
QRecord contact = contact(1).withValue("firstName", "{{owner.name}}");
|
||||
assertThat(CrmEmails.render("{{contact.firstName}} {{contact.lastName}}", contact)).isEqualTo("{{owner.name}} Smith");
|
||||
assertThrows(QException.class, () -> CrmEmails.send(transport, contact, 999, null, null, "email", "missing", null));
|
||||
assertThrows(QException.class, () -> CrmEmails.send(transport, contact, null, "subject", "", "email", "missing", null));
|
||||
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "template").withValue("bodyText", "template"));
|
||||
CrmEmails.send(transport, contact, 1, "override", "body", "email", "1:run", null);
|
||||
assertThat(transport.subject).isEqualTo("override");
|
||||
assertThat(transport.body).isEqualTo("body");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Skips opt out missing address and unavailable transport.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void skipsOptOutMissingAddressAndUnavailableTransport() throws Exception
|
||||
{
|
||||
List<QRecord> contacts = List.of(contact(1).withValue("doNotEmail", true), contact(2).withValue("email", null), contact(3));
|
||||
for(QRecord contact : contacts)
|
||||
{
|
||||
put("crm_contact", contact);
|
||||
}
|
||||
transport.ready = false;
|
||||
CrmAction.Load load = load();
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
load.runOnePage(new RunBackendStepInput().withProcessName("crm_send_email").withRecords(contacts), output);
|
||||
assertThat(load.getProcessSummary(output, true)).hasSize(3);
|
||||
assertThat(transport.sends).isZero();
|
||||
assertThat(rows("crm_email_message")).isEmpty();
|
||||
assertThat(rows("crm_activity")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Process identity uses core uuid.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void processIdentityUsesCoreUuid() throws Exception
|
||||
{
|
||||
var output = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_send_email"));
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getValues().get("crmEmailProcessUUID")).isEqualTo(output.getProcessUUID());
|
||||
var listener = new CrmEmailProcessIdentity();
|
||||
assertThat(listener.appliesTo("crm_send_email")).isTrue();
|
||||
assertThat(listener.appliesTo("crm_other")).isFalse();
|
||||
listener.onProcessCompleted(new RunProcessInput(), output);
|
||||
listener.onProcessFailed(new RunProcessInput(), new Exception("test"));
|
||||
assertThat(CrmActions.inputFields(CrmActions.action("crm_send_email"))).extracting(field -> field.getName())
|
||||
.containsExactly("emailTemplateId", "subject", "body");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active sequence sends marketing and advances once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void activeSequenceSendsMarketingAndAdvancesOnce() throws Exception
|
||||
{
|
||||
seedSequence(true, true);
|
||||
sequence();
|
||||
assertThat(transport.key).isEqualTo("crm:seq:1:1");
|
||||
assertThat(transport.marketing).isTrue();
|
||||
assertThat(rows("crm_email_message")).hasSize(1);
|
||||
assertActivity("SEQUENCE_STEP");
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.COMPLETED.getId());
|
||||
sequence();
|
||||
assertThat(transport.sends).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Paused sequence does not advance or send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pausedSequenceDoesNotAdvanceOrSend() throws Exception
|
||||
{
|
||||
seedSequence(false, true);
|
||||
sequence();
|
||||
assertThat(transport.sends).isZero();
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("currentStepNumber")).isZero();
|
||||
assertThat(Sink.alerts).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing consent pauses enrollment with reason.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingConsentPausesEnrollmentWithReason() throws Exception
|
||||
{
|
||||
seedSequence(true, false);
|
||||
sequence();
|
||||
QRecord enrollment = GetAction.execute("crm_sequence_enrollment", 1);
|
||||
assertThat(enrollment.getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.PAUSED.getId());
|
||||
assertThat(enrollment.getValueString("lastFailureMessage")).isEqualTo("no_email_consent");
|
||||
assertThat(enrollment.getValueInteger("failureCount")).isZero();
|
||||
assertThat(transport.sends).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport failures raise on third attempt.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void transportFailuresRaiseOnThirdAttempt() throws Exception
|
||||
{
|
||||
seedSequence(true, true);
|
||||
transport.fail = true;
|
||||
sequence();
|
||||
sequence();
|
||||
sequence();
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("failureCount")).isEqualTo(3);
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.FAILED.getId());
|
||||
assertThat(Sink.alerts).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAlertTypeName()).isEqualTo("crm.sequence.failing");
|
||||
assertThat(signal.getDedupKey()).isEqualTo("enrollment:1");
|
||||
});
|
||||
assertThat(rows("crm_email_message")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unavailable transport raises then resolves without failing enrollment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableTransportRaisesThenResolvesWithoutFailingEnrollment() throws Exception
|
||||
{
|
||||
seedSequence(true, true);
|
||||
transport.ready = false;
|
||||
sequence();
|
||||
assertThat(Sink.alerts).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAlertTypeName()).isEqualTo("crm.email.unavailable");
|
||||
assertThat(signal.getDedupKey()).isEqualTo("transport");
|
||||
});
|
||||
assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("failureCount")).isZero();
|
||||
transport.ready = true;
|
||||
sequence();
|
||||
assertThat(Sink.resolutions).hasSize(1);
|
||||
assertThat(transport.sends).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery sync uses recent nonfinal messages and preserves dates.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deliverySyncUsesRecentNonfinalMessagesAndPreservesDates() throws Exception
|
||||
{
|
||||
contact(1);
|
||||
message(1, CrmEmailStatus.SENT, Instant.now());
|
||||
message(2, CrmEmailStatus.SENT, Instant.now().minus(31, ChronoUnit.DAYS));
|
||||
message(3, CrmEmailStatus.FAILED, Instant.now());
|
||||
Instant now = Instant.now();
|
||||
transport.deliveries.put("key:1", new CrmEmailTransport.Delivery("provider", CrmEmailStatus.CLICKED, now, now, now, now, null));
|
||||
sync();
|
||||
assertThat(transport.lookups).containsExactly("key:1");
|
||||
QRecord message = GetAction.execute("crm_email_message", 1);
|
||||
assertThat(message.getValueInstant("deliveredDate")).isEqualTo(now);
|
||||
assertThat(message.getValueInstant("firstOpenedDate")).isEqualTo(now);
|
||||
assertThat(message.getValueInstant("firstClickedDate")).isEqualTo(now);
|
||||
transport.deliveries.put("key:1", new CrmEmailTransport.Delivery("provider", CrmEmailStatus.CLICKED, null, null, null, null, null));
|
||||
sync();
|
||||
assertThat(GetAction.execute("crm_email_message", 1).getValueInstant("firstOpenedDate")).isEqualTo(now);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounce and complaint withdraw once and suppress contact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bounceAndComplaintWithdrawOnceAndSuppressContact() throws Exception
|
||||
{
|
||||
for(int id = 1; id <= 2; id++)
|
||||
{
|
||||
contact(id);
|
||||
message(id, CrmEmailStatus.DELIVERED, Instant.now());
|
||||
CrmEmailStatus status = id == 1 ? CrmEmailStatus.BOUNCED : CrmEmailStatus.COMPLAINED;
|
||||
transport.deliveries.put("key:" + id, new CrmEmailTransport.Delivery("provider", status, null, null, null, null, Instant.now()));
|
||||
}
|
||||
sync();
|
||||
sync();
|
||||
assertThat(rows("crm_contact")).allSatisfy(row -> assertThat(row.getValueBoolean("doNotEmail")).isTrue());
|
||||
assertThat(rows("crm_consent_record")).hasSize(2).allSatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueInteger("consentType")).isEqualTo(CrmConsentType.EMAIL.getId());
|
||||
assertThat(row.getValueInteger("status")).isEqualTo(CrmConsentStatus.WITHDRAWN.getId());
|
||||
});
|
||||
assertThat(rows("crm_consent_record")).extracting(row -> row.getValueString("source")).containsExactlyInAnyOrder("Hard bounce", "Complaint");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Absent email qbit is no op.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void absentEmailQbitIsNoOp() throws Exception
|
||||
{
|
||||
message(1, CrmEmailStatus.SENT, Instant.now());
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
new SyncEmailDeliveryProcess().run(new RunBackendStepInput(), output);
|
||||
assertThat(output.getValueInteger("updatedCount")).isZero();
|
||||
assertThat(new CrmEmailTransport(CrmApi.config()).lookup("key:1")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stale pending members expire without new consent rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stalePendingMembersExpireWithoutNewConsentRows() throws Exception
|
||||
{
|
||||
for(int id = 1; id <= 3; id++)
|
||||
{
|
||||
put("crm_email_list_member", new QRecord().withValue("id", id).withValue("emailListId", 1).withValue("email", "a" + id + "@example.test")
|
||||
.withValue("status", id == 3 ? CrmListMemberStatus.SUBSCRIBED.getId() : CrmListMemberStatus.PENDING_CONFIRMATION.getId())
|
||||
.withValue("confirmationSentDate", Instant.now().minus(id == 2 ? 13 : 15, ChronoUnit.DAYS)).withValue("confirmationTokenHash", "hash"));
|
||||
}
|
||||
put("crm_consent_record", new QRecord().withValue("id", 1).withValue("expiryDate", Instant.now().minusSeconds(1)));
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
new ExpireConsentsProcess().run(new RunBackendStepInput(), output);
|
||||
assertThat(output.getValueInteger("expiredPendingCount")).isEqualTo(1);
|
||||
assertThat(output.getValueInteger("expiredConsentCount")).isEqualTo(1);
|
||||
assertThat(GetAction.execute("crm_email_list_member", 1).getValue("confirmationTokenHash")).isNull();
|
||||
assertThat(GetAction.execute("crm_email_list_member", 1).getValueInteger("status")).isEqualTo(CrmListMemberStatus.UNSUBSCRIBED.getId());
|
||||
assertThat(GetAction.execute("crm_email_list_member", 2).getValueString("confirmationTokenHash")).isEqualTo("hash");
|
||||
assertThat(rows("crm_consent_record")).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Schedules require scheduler and can be disabled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void schedulesRequireSchedulerAndCanBeDisabled() throws Exception
|
||||
{
|
||||
Map<String, Integer> intervals = Map.of("crm_process_sequence_steps", 300, "crm_calculate_lead_scores", 3600,
|
||||
"crm_sync_email_delivery", 900, "crm_expire_consents", 86400);
|
||||
for(String name : intervals.keySet())
|
||||
{
|
||||
assertThat(QContext.getQInstance().getProcess(name).getSchedule()).isNull();
|
||||
}
|
||||
CrmQBitConfig config = new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME).withSchedulerName("jobs");
|
||||
var output = new CrmQBitProducer().withQBitConfig(config).produce(new com.kingsrook.qqq.backend.core.model.metadata.QInstance());
|
||||
for(var process : output.getEach(com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData.class))
|
||||
{
|
||||
if(intervals.containsKey(process.getName()))
|
||||
{
|
||||
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(intervals.get(process.getName()));
|
||||
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("jobs");
|
||||
}
|
||||
}
|
||||
assertThat(config.getSchedulesEnabled()).isTrue();
|
||||
output = new CrmQBitProducer().withQBitConfig(config.withSchedulesEnabled(false)).produce(new com.kingsrook.qqq.backend.core.model.metadata.QInstance());
|
||||
assertThat(output.getEach(com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData.class))
|
||||
.allSatisfy(process -> assertThat(process.getSchedule()).isNull());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration and alerts use core metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void collaborationAndAlertsUseCoreMetadata() throws Exception
|
||||
{
|
||||
for(String table : List.of("crm_contact", "crm_company", "crm_deal", "crm_activity"))
|
||||
{
|
||||
var features = RecordFeaturesMetaData.of(QContext.getQInstance().getTable(table));
|
||||
for(RecordFeature feature : List.of(RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.ATTACHMENTS,
|
||||
RecordFeature.FOLLOW, RecordFeature.TIMELINE, RecordFeature.RECENTS, RecordFeature.FAVORITES))
|
||||
{
|
||||
assertThat(features.getEnabled().get(feature)).isTrue();
|
||||
}
|
||||
assertThat(features.getOwnership().getOwnerFieldName()).isEqualTo("ownerUserId");
|
||||
assertThat(features.getEnabled().get(RecordFeature.TASKS)).isEqualTo("crm_activity".equals(table) ? null : true);
|
||||
assertThat(features.getEnabled().get(RecordFeature.ASSIGNMENT)).isEqualTo("crm_activity".equals(table) ? null : true);
|
||||
}
|
||||
assertThat(AlertsInstanceMetaData.of(QContext.getQInstance()).getAlertTypes()).containsKeys("crm.sequence.failing", "crm.email.unavailable",
|
||||
"crm.lead.assigned", "crm.deal.won", "crm.deal.rotting", "crm.task.due");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scoring uses dates incremental changes and daily full pass.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringUsesDatesIncrementalChangesAndDailyFullPass() throws Exception
|
||||
{
|
||||
put("crm_contact", contact(1).withValue("lastActivityDate", Instant.now().minus(20, ChronoUnit.DAYS)).withValue("modifyDate", Instant.now().minusSeconds(300)));
|
||||
put("crm_contact", contact(2).withValue("lastActivityDate", Instant.now().minus(2, ChronoUnit.DAYS)).withValue("modifyDate", Instant.now().minusSeconds(300)));
|
||||
rule(1, "lastActivityDate", CrmScoreOperator.DAYS_AGO_GT, "10", -5);
|
||||
rule(2, "lastActivityDate", CrmScoreOperator.DAYS_AGO_LT, "10", 10);
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(2);
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueInteger("leadScore")).isEqualTo(-5);
|
||||
assertThat(GetAction.execute("crm_contact", 2).getValueInteger("leadScore")).isEqualTo(10);
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isZero();
|
||||
CrmRecords.update("crm_contact", new QRecord().withValue("id", 2).withValue("firstName", "Changed"));
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(1);
|
||||
assertThat(scores(true).getValueInteger("totalContacts")).isEqualTo(2);
|
||||
QRecord state = rows("crm_job_state").getFirst();
|
||||
CrmRecords.update("crm_job_state", state.withValue("lastFullPass", Instant.now().minus(25, ChronoUnit.HOURS)));
|
||||
assertThat(scores(false).getValueInteger("totalContacts")).isEqualTo(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scoring handles calendar dates and malformed rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringHandlesCalendarDatesAndMalformedRules() throws Exception
|
||||
{
|
||||
put("crm_contact", contact(1).withValue("lastName", LocalDate.now().minusDays(30).toString()));
|
||||
rule(1, "lastName", CrmScoreOperator.DAYS_AGO_GT, "10", 7);
|
||||
rule(2, "firstName", CrmScoreOperator.DAYS_AGO_LT, "bad", 100);
|
||||
rule(3, "email", CrmScoreOperator.DAYS_AGO_GT, "10", 100);
|
||||
scores(true);
|
||||
assertThat(GetAction.execute("crm_contact", 1).getValueInteger("leadScore")).isEqualTo(7);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert assignment preserves owners and notifies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void insertAssignmentPreservesOwnersAndNotifies() throws Exception
|
||||
{
|
||||
for(CrmAssignableEntity entity : CrmAssignableEntity.values())
|
||||
{
|
||||
put("crm_assignment_rule", new QRecord().withValue("id", entity.getId()).withValue("name", "Assign")
|
||||
.withValue("entityType", entity.getId()).withValue("assignStrategy", CrmAssignStrategy.SPECIFIC_USER.getId())
|
||||
.withValue("assignToUserId", "assigned").withValue("isActive", true).withValue("sortOrder", 1));
|
||||
}
|
||||
QRecord inserted = CrmRecords.insert("crm_contact", new QRecord().withValue("firstName", "New").withValue("lastName", "Contact"));
|
||||
assertThat(GetAction.execute("crm_contact", inserted.getValue("id")).getValueString("ownerUserId")).isEqualTo("assigned");
|
||||
CrmRecords.insert("crm_contact", new QRecord().withValue("firstName", "Already").withValue("lastName", "Owned").withValue("ownerUserId", "explicit"));
|
||||
put("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales"));
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("pipelineId", 1).withValue("name", "Open"));
|
||||
QRecord deal = CrmRecords.insert("crm_deal", new QRecord().withValue("name", "New deal").withValue("pipelineId", 1)
|
||||
.withValue("pipelineStageId", 1).withValue("ownerUserId", "system").withValue("currencyCode", "USD"));
|
||||
assertThat(GetAction.execute("crm_deal", deal.getValue("id")).getValueString("ownerUserId")).isEqualTo("assigned");
|
||||
assertThat(Sink.notifications).hasSize(2).allSatisfy(signal ->
|
||||
{
|
||||
assertThat(signal.getSourceName()).isEqualTo("crm.lead.assigned");
|
||||
assertThat(signal.getAudience()).isEqualTo(NotificationAudience.user("assigned"));
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Won and rotting notifications target owner and deduplicate stage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wonAndRottingNotificationsTargetOwnerAndDeduplicateStage() throws Exception
|
||||
{
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("name", "Open").withValue("pipelineId", 1).withValue("rotDays", 5));
|
||||
put("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("name", "Won").withValue("pipelineId", 1).withValue("isClosedWon", true));
|
||||
put("crm_deal", new QRecord().withValue("id", 1).withValue("name", "Old deal").withValue("pipelineId", 1)
|
||||
.withValue("pipelineStageId", 1).withValue("ownerUserId", "owner").withValue("stageEnteredDate", Instant.now().minus(10, ChronoUnit.DAYS)));
|
||||
CrmDealNotifications.checkRotting();
|
||||
CrmDealNotifications.checkRotting();
|
||||
assertThat(Sink.notifications).singleElement().satisfies(signal -> assertThat(signal.getSourceKey()).isEqualTo("deal:1:stage:1"));
|
||||
CrmRecords.update("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 2));
|
||||
assertThat(Sink.notifications).hasSize(2);
|
||||
assertThat(Sink.notifications.getLast().getSourceName()).isEqualTo("crm.deal.won");
|
||||
assertThat(Sink.notifications.getLast().getAudience()).isEqualTo(NotificationAudience.user("owner"));
|
||||
CrmRecords.update("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 2));
|
||||
CrmDealNotifications.checkRotting();
|
||||
assertThat(Sink.notifications).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Company loading scales with pages, not contacts or rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringLoadsCompaniesOncePerPage() throws Exception
|
||||
{
|
||||
put("crm_company", new QRecord().withValue("id", 1).withValue("name", "Example"));
|
||||
for(int id = 1; id <= 201; id++)
|
||||
{
|
||||
put("crm_contact", contact(id).withValue("companyId", 1));
|
||||
}
|
||||
rule(1, "company.name", CrmScoreOperator.EQUALS, "Example", 3);
|
||||
rule(2, "company.name", CrmScoreOperator.IS_SET, null, 2);
|
||||
CompanyReads.count = 0;
|
||||
QContext.getQInstance().getTable("crm_company").withCustomizer(
|
||||
com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD, new QCodeReference(CompanyReads.class));
|
||||
assertThat(scores(true).getValueInteger("updatedCount")).isEqualTo(201);
|
||||
// Two explicit page loads, plus at most two core audit-label translations.
|
||||
assertThat(CompanyReads.count).isBetween(2, 4);
|
||||
assertThat(GetAction.execute("crm_contact", 201).getValueInteger("leadScore")).isEqualTo(5);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed scoring must leave the successful checkpoint unchanged.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scoringFailureDoesNotAdvanceCheckpoint() throws Exception
|
||||
{
|
||||
contact(1);
|
||||
scores(true);
|
||||
Instant checkpoint = rows("crm_job_state").getFirst().getValueInstant("lastSuccess");
|
||||
rule(1, "firstName", CrmScoreOperator.IS_SET, null, 1);
|
||||
QContext.getQInstance().getTable("crm_contact").withCustomizer(
|
||||
com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(RejectScore.class));
|
||||
assertThrows(QException.class, () -> scores(true));
|
||||
assertThat(rows("crm_job_state").getFirst().getValueInstant("lastSuccess")).isEqualTo(checkpoint);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry repairs a missing activity without sending the persisted message again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retryRepairsActivityAfterPartialRecording() throws Exception
|
||||
{
|
||||
QRecord contact = contact(1);
|
||||
put("crm_email_message", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("subject", "Already sent")
|
||||
.withValue("idempotencyKey", "crm:email:1:retry").withValue("status", CrmEmailStatus.SENT.getId()));
|
||||
CrmEmails.send(transport, contact, null, "Already sent", "body", "email", "1:retry", null);
|
||||
assertThat(transport.sends).isZero();
|
||||
assertActivity("EMAIL");
|
||||
assertThat(GetAction.execute("crm_email_message", 1).getValue("activityId")).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count actual backend company queries.
|
||||
*******************************************************************************/
|
||||
public static class CompanyReads extends com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer
|
||||
{
|
||||
private static int count;
|
||||
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
count++;
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return a record-level error to exercise checked background writes.
|
||||
*******************************************************************************/
|
||||
public static class RejectScore extends com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
records.forEach(row -> row.addError(new com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage("Rejected score")));
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send action with a local fake.
|
||||
*******************************************************************************/
|
||||
private CrmAction.Load load()
|
||||
{
|
||||
return (new CrmAction.Load()
|
||||
{
|
||||
@Override
|
||||
protected CrmEmailTransport emailTransport()
|
||||
{
|
||||
return (transport);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequence runner with a local fake.
|
||||
*******************************************************************************/
|
||||
private void sequence() throws QException
|
||||
{
|
||||
new ProcessSequenceStepsProcess()
|
||||
{
|
||||
@Override
|
||||
protected CrmEmailTransport emailTransport()
|
||||
{
|
||||
return (transport);
|
||||
}
|
||||
}.run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sync with a local fake.
|
||||
*******************************************************************************/
|
||||
private void sync() throws QException
|
||||
{
|
||||
new SyncEmailDeliveryProcess()
|
||||
{
|
||||
@Override
|
||||
protected CrmEmailTransport emailTransport()
|
||||
{
|
||||
return (transport);
|
||||
}
|
||||
}.run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run score work using normal QQQ actions.
|
||||
*******************************************************************************/
|
||||
private RunBackendStepOutput scores(boolean full) throws QException
|
||||
{
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
new CalculateLeadScoresProcess().run(new RunBackendStepInput().addValue("fullRecalc", full), output);
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Contact fixture.
|
||||
*******************************************************************************/
|
||||
private QRecord contact(int id) throws QException
|
||||
{
|
||||
QRecord row = new QRecord().withValue("id", id).withValue("firstName", "Alice").withValue("lastName", "Smith")
|
||||
.withValue("email", "alice" + id + "@example.test").withValue("ownerUserId", "owner");
|
||||
put("crm_contact", row);
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequence fixtures use enum identifiers.
|
||||
*******************************************************************************/
|
||||
private void seedSequence(boolean active, boolean eligible) throws QException
|
||||
{
|
||||
contact(1);
|
||||
put("crm_email_sequence", new QRecord().withValue("id", 1).withValue("name", "Sequence").withValue("isActive", active).withValue("totalSteps", 1));
|
||||
put("crm_email_template", new QRecord().withValue("id", 1).withValue("subject", "Hello {{contact.firstName}}").withValue("bodyText", "Welcome"));
|
||||
put("crm_sequence_step", new QRecord().withValue("id", 1).withValue("sequenceId", 1).withValue("stepNumber", 1)
|
||||
.withValue("stepType", CrmSequenceStepType.EMAIL.getId()).withValue("emailTemplateId", 1));
|
||||
put("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("sequenceId", 1).withValue("contactId", 1)
|
||||
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("currentStepNumber", 0).withValue("failureCount", 0)
|
||||
.withValue("nextStepDate", Instant.now().minusSeconds(60)));
|
||||
if(eligible)
|
||||
{
|
||||
put("crm_consent_record", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("consentType", CrmConsentType.EMAIL.getId())
|
||||
.withValue("status", CrmConsentStatus.GRANTED.getId()).withValue("consentDate", Instant.now()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport-linked message fixture.
|
||||
*******************************************************************************/
|
||||
private void message(int id, CrmEmailStatus status, Instant created) throws QException
|
||||
{
|
||||
put("crm_email_message", new QRecord().withValue("id", id).withValue("contactId", id).withValue("subject", "Test")
|
||||
.withValue("idempotencyKey", "key:" + id).withValue("status", status.getId()).withValue("createDate", created));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Score rule fixture.
|
||||
*******************************************************************************/
|
||||
private void rule(int id, String field, CrmScoreOperator operator, String expected, int points) throws QException
|
||||
{
|
||||
put("crm_lead_score_rule", new QRecord().withValue("id", id).withValue("name", "Rule " + id).withValue("fieldPath", field)
|
||||
.withValue("operator", operator.getId()).withValue("fieldValue", expected).withValue("scoreAdjustment", points).withValue("isActive", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert the activity's stable code.
|
||||
*******************************************************************************/
|
||||
private void assertActivity(String code) throws QException
|
||||
{
|
||||
QRecord activity = rows("crm_activity").getFirst();
|
||||
assertThat(GetAction.execute("crm_activity_type", activity.getValue("activityTypeId")).getValueString("code")).isEqualTo(code);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert isolated fixture data.
|
||||
*******************************************************************************/
|
||||
private void put(String table, QRecord row) throws QException
|
||||
{
|
||||
if(row.getValue("id") != null && GetAction.execute(table, row.getValue("id")) != null)
|
||||
{
|
||||
MemoryRecordStore.getInstance().update(new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(table).withRecord(row), true);
|
||||
}
|
||||
else
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(row), true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query in the current session.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fake transport with independent send and delivery controls.
|
||||
*******************************************************************************/
|
||||
private static class FakeTransport extends CrmEmailTransport
|
||||
{
|
||||
private boolean ready = true;
|
||||
private boolean fail;
|
||||
private boolean marketing;
|
||||
private int sends;
|
||||
private String key;
|
||||
private String subject;
|
||||
private String body;
|
||||
private final Map<String, Delivery> deliveries = new HashMap<>();
|
||||
private final List<String> lookups = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** No external provider is installed.
|
||||
*******************************************************************************/
|
||||
FakeTransport()
|
||||
{
|
||||
super(new CrmQBitConfig());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrmReadiness readiness()
|
||||
{
|
||||
return (ready ? CrmReadiness.QBIT_EMAIL : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Delivery sendMessage(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
if(fail)
|
||||
{
|
||||
throw (new QException("Fake transport failure"));
|
||||
}
|
||||
sends++;
|
||||
key = "crm:" + purpose + ":" + id;
|
||||
this.subject = subject;
|
||||
this.body = body;
|
||||
this.marketing = marketing;
|
||||
return (new Delivery("provider-1", CrmEmailStatus.QUEUED, null, null, null, null, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Delivery> lookup(String key)
|
||||
{
|
||||
lookups.add(key);
|
||||
return (Optional.ofNullable(deliveries.get(key)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the core dispatch without an alerts backend.
|
||||
*******************************************************************************/
|
||||
public static class Sink extends LoggingAlertSink
|
||||
{
|
||||
private static final List<NotificationSignal> notifications = new ArrayList<>();
|
||||
private static final List<AlertSignal> alerts = new ArrayList<>();
|
||||
private static final List<AlertResolution> resolutions = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
{
|
||||
notifications.add(signal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void raise(AlertSignal signal)
|
||||
{
|
||||
alerts.add(signal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resolve(AlertResolution resolution)
|
||||
{
|
||||
resolutions.add(resolution);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class CrmActionsTest extends BaseTest
|
||||
void metadataIsComplete()
|
||||
{
|
||||
assertThat(CrmActions.ACTIONS).hasSize(28);
|
||||
assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(30);
|
||||
assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(32);
|
||||
for(CrmActions.Action action : CrmActions.ACTIONS)
|
||||
{
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(action.name());
|
||||
@@ -154,7 +154,7 @@ class CrmActionsTest extends BaseTest
|
||||
assertThat(process.getMaxInputRecords()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores"))
|
||||
for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores", "crm_sync_email_delivery", "crm_expire_consents"))
|
||||
{
|
||||
assertThat(QContext.getQInstance().getProcess(name).getTableName()).isNull();
|
||||
}
|
||||
|
||||
+9
-9
@@ -60,10 +60,10 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Email step should create an activity record.
|
||||
** Email without recorded consent pauses without creating a fake activity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEmailStepCreatesActivity() throws QException
|
||||
void testEmailStepRequiresConsent() throws QException
|
||||
{
|
||||
insertActivityType("Email");
|
||||
insertActivityType("Task");
|
||||
@@ -91,8 +91,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
QueryOutput activityQuery = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME));
|
||||
|
||||
assertThat(activityQuery.getRecords()).isNotEmpty();
|
||||
assertThat(activityQuery.getRecords().get(0).getValueString("subject")).contains("Sequence email");
|
||||
assertThat(activityQuery.getRecords()).isEmpty();
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// verify enrollment was advanced //
|
||||
@@ -101,7 +100,8 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
new QueryInput(SequenceEnrollment.TABLE_NAME));
|
||||
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentQuery.getRecords().get(0));
|
||||
assertEquals(1, enrollment.getCurrentStepNumber());
|
||||
assertEquals(0, enrollment.getCurrentStepNumber());
|
||||
assertEquals(CrmEnrollmentStatus.PAUSED.getId(), enrollment.getStatus());
|
||||
}
|
||||
|
||||
|
||||
@@ -142,10 +142,10 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After 3 failures, enrollment should be marked as FAILED.
|
||||
** Opt-out pauses an enrollment without counting it as a delivery failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailureCounting() throws QException
|
||||
void testOptOutPausesWithoutCountingFailure() throws QException
|
||||
{
|
||||
Integer sequenceId = insertSequence("Fail Seq", true, 2, false);
|
||||
Integer contactId = insertContact("Carol", "Fail", true); // doNotEmail = true
|
||||
@@ -181,8 +181,8 @@ class ProcessSequenceStepsProcessTest extends BaseTest
|
||||
new QueryInput(SequenceEnrollment.TABLE_NAME));
|
||||
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentQuery.getRecords().get(0));
|
||||
assertEquals(CrmEnrollmentStatus.FAILED.getId(), enrollment.getStatus());
|
||||
assertEquals(3, enrollment.getFailureCount());
|
||||
assertEquals(CrmEnrollmentStatus.PAUSED.getId(), enrollment.getStatus());
|
||||
assertEquals(2, enrollment.getFailureCount());
|
||||
assertThat(enrollment.getLastFailureMessage()).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user