Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da0edfc6c
|
||
|
|
b8dc4041a7
|
||
|
|
2903a1e546
|
||
|
|
bd4b6fd9c4
|
||
|
|
6acd5ea6e2
|
||
|
|
fc2effb070
|
||
|
|
c35b3478ca
|
||
|
|
6ecaa4c255 |
No files matched your search
@@ -138,3 +138,82 @@ The later intake APIs, public subscription routes, collaboration integration, re
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
|
||||
Licensed under [Apache License 2.0](LICENSE).
|
||||
|
||||
## Integrating: intake and marketing APIs
|
||||
|
||||
Use `com.kingsrook.qbits.crm.api` from a host with an initialized `QContext`.
|
||||
The API enforces the current session's CRM permissions and record locks; it does
|
||||
not elevate website callers. The host supplies authentication, tenant scope and
|
||||
abuse protection before invoking intake. First use initializes the six activity
|
||||
codes through normal table actions and needs CRM settings write access.
|
||||
|
||||
Configure the signing secret from your secret store and the externally reachable
|
||||
base URL. Never put the secret in source control:
|
||||
|
||||
```java
|
||||
CrmQBitConfig config = new CrmQBitConfig()
|
||||
.withDefaultBackendNameForTables("main")
|
||||
.withTokenSecret(secretFromHost)
|
||||
.withPublicBaseUrl("https://example.com")
|
||||
.withMessagingProviderName("email"); // core fallback when qbit-email is absent
|
||||
```
|
||||
|
||||
`CrmApi.isInstalled(instance)` checks installation. `CrmApi.readiness()` returns
|
||||
`NOT_CONFIGURED`, `QBIT_EMAIL` or `CORE_MESSAGING`. qbit-email 0.1.0-RC.3 is an
|
||||
optional dependency, preferred when installed; its own readiness and suppression
|
||||
rules apply. Core fallback uses the named host messaging provider. The core path
|
||||
has no durable send deduplication; qbit-email uses `sendOnce` with
|
||||
`crm:<purpose>:<id>`. A refreshed confirmation uses a new token hash in that id.
|
||||
|
||||
```java
|
||||
CrmIntakeApi intake = new CrmIntakeApi();
|
||||
CrmFormResult lead = intake.submitForm(new CrmFormRequest(
|
||||
"contact", FormType.CONTACT, "Ada", "Lovelace", "ada@example.com",
|
||||
null, "Example", "Please contact me", false, "Reply to my inquiry",
|
||||
"search", "organic", "launch", null, null,
|
||||
"https://example.com/contact", null, null, null));
|
||||
|
||||
CrmSubscribeResult signup = intake.subscribe(new CrmSubscribeRequest(
|
||||
"newsletter", "ada@example.com", "Ada", "Lovelace",
|
||||
"Send me the monthly newsletter", "Website",
|
||||
"search", "organic", "launch", null, null, null, null));
|
||||
```
|
||||
|
||||
Create an active `crm_email_list` with its required unique `listKey` before
|
||||
subscribing. Double opt-in defaults to true. Pending members receive a signed
|
||||
confirmation link and become eligible only after confirmation. Requests retain
|
||||
the exact consent wording and submission evidence. Contact forms without an
|
||||
explicit opt-in record **Reply to inquiry / Legitimate interest**, never Email
|
||||
consent. Optional visitor/client identifiers must be precomputed SHA-256 hex
|
||||
hashes; salt the client hash in the host. Raw IPs and user-agent strings have no
|
||||
storage fields. Missing subscriber names use the label "Not provided" because
|
||||
the CRM contact model requires both names.
|
||||
|
||||
```java
|
||||
CrmMarketingApi marketing = new CrmMarketingApi();
|
||||
List<RecipientScopeOption> scopes = marketing.recipientScopeOptions();
|
||||
List<CrmRecipient> recipients = marketing.resolveRecipients("crm-list:1");
|
||||
Eligibility eligibility = marketing.checkEmailEligibility(lead.contactId());
|
||||
marketing.recordActivity(lead.contactId(), ActivityKind.NOTE, "Qualified lead", "Requested a demo");
|
||||
String unsubscribeUrl = marketing.buildUnsubscribeUrl(lead.contactId(), null);
|
||||
marketing.recordUnsubscribe(lead.contactId(), null, "Customer request");
|
||||
```
|
||||
|
||||
Recipient scopes are `crm-list:<id>` and `crm-contact:<id>`. Addresses are returned
|
||||
only when eligible. The latest Email consent must be granted and unexpired;
|
||||
`doNotEmail`, a global unsubscribe date, or any non-subscribed membership
|
||||
suppresses delivery. List-level withdrawal also appends a contact-level Email
|
||||
withdrawal, so it suppresses marketing until subsequent explicit consent; it
|
||||
does not set the contact's global unsubscribe date. The API never silently clears
|
||||
that global suppression. `listFormSubmissions(campaign, since)` returns submission
|
||||
and contact IDs plus timestamps, without form contents.
|
||||
|
||||
When Javalin is present, CRM registers GET and POST under
|
||||
`/qqq/hooks/crm/confirm/{token}` and `/qqq/hooks/crm/unsubscribe/{token}`.
|
||||
GET displays an accessible confirmation button; POST performs the operation.
|
||||
Links expire after 90 days. Confirmation tokens are stored only as SHA-256 hashes;
|
||||
refreshing or withdrawing a pending subscription invalidates its previous token.
|
||||
The signed route uses a system session solely for the IDs authorized by that link.
|
||||
Direct callers can also use `confirm(token)`, `unsubscribe(token)` and
|
||||
`unsubscribeByContact(contactId, listIdOrNull, source)`. Pages contain no personal
|
||||
data and responses disable caching and referrer forwarding.
|
||||
+36
-4
@@ -71,6 +71,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_activity_participant_contact_id ON crm_activit
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_activity_participant_user_id ON crm_activity_participant (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_activity_type (
|
||||
code VARCHAR(50) UNIQUE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
icon_name VARCHAR(50),
|
||||
@@ -144,6 +145,8 @@ CREATE INDEX IF NOT EXISTS ix_crm_company_owner_user_id ON crm_company (owner_us
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_company_parent_company_id ON crm_company (parent_company_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
consent_text TEXT,
|
||||
evidence_form_submission_id INTEGER,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
contact_id INTEGER NOT NULL,
|
||||
consent_type INTEGER NOT NULL,
|
||||
@@ -152,13 +155,28 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
source VARCHAR(250),
|
||||
consent_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
expiry_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_record (contact_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
first_touch_utm_source VARCHAR(250),
|
||||
first_touch_utm_medium VARCHAR(250),
|
||||
first_touch_utm_campaign VARCHAR(250),
|
||||
first_touch_utm_content VARCHAR(250),
|
||||
first_touch_utm_term VARCHAR(250),
|
||||
first_touch_landing_page VARCHAR(1000),
|
||||
first_touch_referrer VARCHAR(1000),
|
||||
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_touch_utm_source VARCHAR(250),
|
||||
last_touch_utm_medium VARCHAR(250),
|
||||
last_touch_utm_campaign VARCHAR(250),
|
||||
last_touch_utm_content VARCHAR(250),
|
||||
last_touch_utm_term VARCHAR(250),
|
||||
last_touch_landing_page VARCHAR(1000),
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
@@ -300,6 +318,9 @@ CREATE INDEX IF NOT EXISTS ix_crm_deal_stage_history_to_stage_id ON crm_deal_sta
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_stage_history_user_id ON crm_deal_stage_history (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
list_key VARCHAR(250) NOT NULL UNIQUE,
|
||||
require_double_opt_in BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
@@ -315,13 +336,18 @@ CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list_member (
|
||||
confirmation_token_hash VARCHAR(64),
|
||||
confirmation_sent_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
source VARCHAR(250),
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email_list_id INTEGER NOT NULL,
|
||||
contact_id INTEGER,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
status INTEGER DEFAULT 1 NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
unsubscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
subscription_source VARCHAR(250),
|
||||
form_submission_id INTEGER,
|
||||
@@ -417,6 +443,12 @@ CREATE INDEX IF NOT EXISTS ix_crm_external_mapping_entity_id ON crm_external_map
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_external_mapping_external_id ON crm_external_mapping (external_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
form_key VARCHAR(250) NOT NULL,
|
||||
marketing_consent BOOLEAN DEFAULT FALSE,
|
||||
consent_text TEXT,
|
||||
landing_page VARCHAR(1000),
|
||||
visitor_key VARCHAR(64),
|
||||
client_hash VARCHAR(64),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
form_type INTEGER NOT NULL,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
@@ -436,8 +468,6 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
conversion_status INTEGER DEFAULT 1 NOT NULL,
|
||||
converted_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
converted_by_user_id TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
metadata TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
@@ -604,3 +634,5 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT uq_crm_win_loss_reason_1 UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
@@ -73,6 +73,17 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>0.1.0-RC.3</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
|
||||
@@ -43,6 +43,12 @@ public class CrmQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
|
||||
|
||||
private String messagingProviderName;
|
||||
|
||||
private String publicBaseUrl;
|
||||
|
||||
private String tokenSecret;
|
||||
|
||||
private String defaultBackendNameForTables;
|
||||
private Boolean inferBackendNames = true;
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
@@ -188,4 +194,62 @@ public class CrmQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenSecret
|
||||
*******************************************************************************/
|
||||
public String getTokenSecret()
|
||||
{
|
||||
return (tokenSecret);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure tokenSecret
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withTokenSecret(String tokenSecret)
|
||||
{
|
||||
this.tokenSecret = tokenSecret;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publicBaseUrl
|
||||
*******************************************************************************/
|
||||
public String getPublicBaseUrl()
|
||||
{
|
||||
return (publicBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure publicBaseUrl
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withPublicBaseUrl(String publicBaseUrl)
|
||||
{
|
||||
this.publicBaseUrl = publicBaseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for messagingProviderName
|
||||
*******************************************************************************/
|
||||
public String getMessagingProviderName()
|
||||
{
|
||||
return (messagingProviderName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure messagingProviderName
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withMessagingProviderName(String messagingProviderName)
|
||||
{
|
||||
this.messagingProviderName = messagingProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -42,6 +42,8 @@ import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -293,7 +295,30 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
output.add(item instanceof QWidgetMetaData widget
|
||||
? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
|
||||
}
|
||||
output.add(new QAlertTypeMetaData().withName("crm.lead.received").withLabel("New web lead")
|
||||
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
|
||||
output.add(new QAlertTypeMetaData().withName("crm.sequence.failing").withLabel("Sequence needs attention")
|
||||
.withSourceQBitName(getQBitMetaData().getName()));
|
||||
output.add(new QAlertTypeMetaData().withName("crm.email.unavailable").withLabel("Email unavailable")
|
||||
.withSourceQBitName(getQBitMetaData().getName()));
|
||||
registerHooks(qInstance);
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Link the route provider only when both optional middleware classes exist.
|
||||
*******************************************************************************/
|
||||
private static void registerHooks(QInstance instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("io.javalin.Javalin");
|
||||
Class.forName("com.kingsrook.qqq.middleware.javalin.QJavalinMetaData");
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return;
|
||||
}
|
||||
com.kingsrook.qbits.crm.webhook.CrmHookRouteProvider.register(instance);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,8 @@ public class ActivityType extends QRecordEntity
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "iconName"))
|
||||
.withUniqueKey(new UniqueKey("code"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "code", "iconName"))
|
||||
.withSection(SectionFactory.defaultT2("isSystem", "sortOrder", "isActive"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
@@ -116,6 +117,9 @@ public class ActivityType extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String code;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -407,4 +411,33 @@ public class ActivityType extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for code
|
||||
*******************************************************************************/
|
||||
public String getCode()
|
||||
{
|
||||
return (this.code);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for code
|
||||
*******************************************************************************/
|
||||
public void setCode(String code)
|
||||
{
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for code
|
||||
*******************************************************************************/
|
||||
public ActivityType withCode(String code)
|
||||
{
|
||||
this.code = code;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable activity codes seeded on first use.
|
||||
*******************************************************************************/
|
||||
public enum ActivityKind
|
||||
{
|
||||
NOTE,
|
||||
EMAIL,
|
||||
CALL,
|
||||
MEETING,
|
||||
FORM_SUBMISSION,
|
||||
SEQUENCE_STEP
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.CrmQBitProducer;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM installation and email readiness for hosts and integrations.
|
||||
*******************************************************************************/
|
||||
public final class CrmApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** CRM facade cannot be instantiated.
|
||||
*******************************************************************************/
|
||||
private CrmApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the CRM metadata is installed in the supplied instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled(QInstance instance)
|
||||
{
|
||||
return (instance != null && instance.getTable("crm_contact") != null && instance.getTable("crm_consent_record") != null
|
||||
&& instance.getTable("crm_email_list_member") != null && instance.getTable("crm_form_submission") != null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report the currently configured email transport.
|
||||
*******************************************************************************/
|
||||
public static CrmReadiness readiness() throws QException
|
||||
{
|
||||
return (isInstalled(QContext.getQInstance()) ? new CrmEmailTransport(config()).readiness() : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve configuration from the current instance, never global mutable state.
|
||||
*******************************************************************************/
|
||||
public static CrmQBitConfig config() throws QUserFacingException
|
||||
{
|
||||
var bit = QContext.getQInstance().getQBits().get(CrmQBitProducer.GROUP_ID + ":" + CrmQBitProducer.ARTIFACT_ID);
|
||||
if(bit == null || !(bit.getConfig() instanceof CrmQBitConfig config))
|
||||
{
|
||||
throw (new QUserFacingException("CRM is not installed in this application."));
|
||||
}
|
||||
return (config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Website submission. visitorKey and clientHash must already be SHA-256 hashes; never pass raw identifiers.
|
||||
*******************************************************************************/
|
||||
public record CrmFormRequest(String formKey, FormType formType, String firstName, String lastName, String email, String phone, String company,
|
||||
String message, boolean marketingConsent, String consentText, String utmSource, String utmMedium, String utmCampaign,
|
||||
String utmContent, String utmTerm, String landingPage, String referrer, String visitorKey, String clientHash)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted submission and linked contact identifiers.
|
||||
*******************************************************************************/
|
||||
public record CrmFormResult(Integer submissionId, Integer contactId, boolean contactCreated)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Campaign attribution reference containing identifiers and time only.
|
||||
*******************************************************************************/
|
||||
public record CrmFormSubmissionRef(Integer submissionId, Integer contactId, Instant submittedAt)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.scoring.CrmAutoAssign;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
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.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaOption;
|
||||
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.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host work: the host or signed public hook validates requests before entry, so caller CRM permissions are not required.
|
||||
** Website intake, evidence-backed consent, double opt-in and signed unsubscribe operations under the current QContext.
|
||||
*******************************************************************************/
|
||||
public class CrmIntakeApi
|
||||
{
|
||||
private final CrmStore store;
|
||||
private final CrmQBitConfig config;
|
||||
private final CrmEmailTransport transport;
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the current instance configuration and standard transport.
|
||||
*******************************************************************************/
|
||||
public CrmIntakeApi() throws QException
|
||||
{
|
||||
this(CrmApi.config(), null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply a transport for host integration or deterministic delivery tests.
|
||||
*******************************************************************************/
|
||||
public CrmIntakeApi(CrmQBitConfig config, CrmEmailTransport transport)
|
||||
{
|
||||
this(config, transport, CrmStore.trusted());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve enforcement when the signed-in marketing API delegates a withdrawal.
|
||||
*******************************************************************************/
|
||||
CrmIntakeApi(CrmQBitConfig config, CrmEmailTransport transport, CrmStore store)
|
||||
{
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
this.transport = transport == null ? new CrmEmailTransport(config) : transport;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Store a form and attribution, recording marketing consent only for explicit opt-in.
|
||||
*******************************************************************************/
|
||||
public CrmFormResult submitForm(CrmFormRequest request) throws QException
|
||||
{
|
||||
validate(request);
|
||||
CrmStarterData.ensure(store);
|
||||
List<QRecord> contacts = contacts(request.email());
|
||||
boolean created = contacts.isEmpty();
|
||||
QRecord contact = contact(request, contacts);
|
||||
Integer id = contact.getValueInteger("id");
|
||||
QRecord submission = submission(request, id);
|
||||
consent(id, request.marketingConsent() ? CrmConsentType.EMAIL : CrmConsentType.REPLY_TO_INQUIRY, CrmConsentStatus.GRANTED,
|
||||
request.marketingConsent() ? CrmLegalBasis.CONSENT : CrmLegalBasis.LEGITIMATE_INTEREST,
|
||||
request.formKey(), request.consentText(), submission.getValueInteger("id"));
|
||||
store.recordActivity(id, ActivityKind.FORM_SUBMISSION, "Form submission", request.message());
|
||||
notifyLead(submission, NotificationAudience.permission("crm.read"));
|
||||
if(!isUnassigned(contact.getValueString("ownerUserId")))
|
||||
{
|
||||
notifyLead(submission, NotificationAudience.user(contact.getValueString("ownerUserId")));
|
||||
}
|
||||
return (new CrmFormResult(submission.getValueInteger("id"), id, created));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Subscribe to an active list. Pending confirmation never grants Email consent.
|
||||
*******************************************************************************/
|
||||
public CrmSubscribeResult subscribe(CrmSubscribeRequest request) throws QException
|
||||
{
|
||||
if(request == null || request.listKey() == null || request.listKey().isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("A mailing list key is required."));
|
||||
}
|
||||
List<QRecord> lists = store.by("crm_email_list", "listKey", request.listKey());
|
||||
if(lists.isEmpty() || !Boolean.TRUE.equals(lists.get(0).getValueBoolean("isActive")))
|
||||
{
|
||||
throw (new QUserFacingException("This mailing list is unavailable."));
|
||||
}
|
||||
QRecord list = lists.get(0);
|
||||
Integer listId = list.getValueInteger("id");
|
||||
String wording = request.consentText() == null || request.consentText().isBlank() ? list.getValueString("consentText") : request.consentText();
|
||||
CrmFormRequest form = new CrmFormRequest(request.listKey(), FormType.NEWSLETTER, request.firstName(), request.lastName(),
|
||||
request.email(), null, null, null, true, wording, request.utmSource(), request.utmMedium(), request.utmCampaign(),
|
||||
request.utmContent(), request.utmTerm(), null, null, request.visitorKey(), request.clientHash());
|
||||
validate(form);
|
||||
List<QRecord> contacts = contacts(request.email());
|
||||
QRecord member = null;
|
||||
if(!contacts.isEmpty())
|
||||
{
|
||||
List<QRecord> members = store.query("crm_email_list_member", new QQueryFilter(
|
||||
new QFilterCriteria("emailListId", QCriteriaOperator.EQUALS, listId),
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contacts.get(0).getValueInteger("id"))));
|
||||
member = members.isEmpty() ? null : members.get(0);
|
||||
if(member != null && CrmListMemberStatus.SUBSCRIBED.getId().equals(member.getValueInteger("status")))
|
||||
{
|
||||
return (new CrmSubscribeResult(SubscriptionStatus.ALREADY_SUBSCRIBED));
|
||||
}
|
||||
}
|
||||
boolean doubleOptIn = !Boolean.FALSE.equals(list.getValueBoolean("requireDoubleOptIn"));
|
||||
CrmTokens tokens = doubleOptIn ? new CrmTokens(config) : null;
|
||||
if(doubleOptIn)
|
||||
{
|
||||
tokens.url("confirm", "configuration-check");
|
||||
if(transport.readiness() == CrmReadiness.NOT_CONFIGURED)
|
||||
{
|
||||
throw (new QUserFacingException("CRM email is not configured for list confirmation."));
|
||||
}
|
||||
}
|
||||
CrmStarterData.ensure(store);
|
||||
QRecord contact = contact(form, contacts);
|
||||
Integer contactId = contact.getValueInteger("id");
|
||||
QRecord submission = submission(form, contactId);
|
||||
QRecord values = new QRecord().withValue("emailListId", listId).withValue("contactId", contactId)
|
||||
.withValue("email", normalize(request.email())).withValue("firstName", request.firstName())
|
||||
.withValue("status", (doubleOptIn ? CrmListMemberStatus.PENDING_CONFIRMATION : CrmListMemberStatus.SUBSCRIBED).getId()).withValue("source", request.source()).withValue("consentText", wording)
|
||||
.withValue("formSubmissionId", submission.getValueInteger("id")).withValue("unsubscribedDate", null)
|
||||
.withValue("subscribedDate", doubleOptIn ? null : Instant.now()).withValue("confirmedDate", null);
|
||||
if(member == null)
|
||||
{
|
||||
member = store.insert("crm_email_list_member", values);
|
||||
}
|
||||
else
|
||||
{
|
||||
member = store.update("crm_email_list_member", values.withValue("id", member.getValueInteger("id")));
|
||||
}
|
||||
Integer memberId = member.getValueInteger("id");
|
||||
if(doubleOptIn)
|
||||
{
|
||||
String token = tokens.issue("confirm", memberId, listId);
|
||||
String hash = CrmTokens.hash(token);
|
||||
store.update("crm_email_list_member", new QRecord().withValue("id", memberId)
|
||||
.withValue("confirmationTokenHash", hash).withValue("confirmationSentDate", Instant.now()));
|
||||
transport.send("confirm", memberId + ":" + hash, contactId, normalize(request.email()), "Confirm your subscription",
|
||||
"Please confirm your subscription: " + tokens.url("confirm", token), true);
|
||||
return (new CrmSubscribeResult(SubscriptionStatus.PENDING_CONFIRMATION));
|
||||
}
|
||||
consent(contactId, CrmConsentType.EMAIL, CrmConsentStatus.GRANTED, CrmLegalBasis.CONSENT, request.source(), wording, submission.getValueInteger("id"));
|
||||
store.update("crm_contact", new QRecord().withValue("id", contactId).withValue("unsubscribeDate", null));
|
||||
return (new CrmSubscribeResult(SubscriptionStatus.SUBSCRIBED));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Confirm the current token only. Repeated confirmation is harmless; refreshed and withdrawn tokens fail.
|
||||
*******************************************************************************/
|
||||
public void confirm(String token) throws QException
|
||||
{
|
||||
CrmTokens.Claims claims = new CrmTokens(config).verify(token, "confirm");
|
||||
QRecord member = store.get("crm_email_list_member", claims.id());
|
||||
if(!Objects.equals(claims.listId(), member.getValueInteger("emailListId"))
|
||||
|| !Objects.equals(CrmTokens.hash(token), member.getValueString("confirmationTokenHash")))
|
||||
{
|
||||
throw (new QUserFacingException("This confirmation link is no longer available. Please subscribe again."));
|
||||
}
|
||||
if(CrmListMemberStatus.SUBSCRIBED.getId().equals(member.getValueInteger("status")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!CrmListMemberStatus.PENDING_CONFIRMATION.getId().equals(member.getValueInteger("status"))
|
||||
|| !Boolean.TRUE.equals(store.get("crm_email_list", claims.listId()).getValueBoolean("isActive")))
|
||||
{
|
||||
throw (new QUserFacingException("This subscription cannot be confirmed."));
|
||||
}
|
||||
store.get("crm_contact", member.getValueInteger("contactId"));
|
||||
consent(member.getValueInteger("contactId"), CrmConsentType.EMAIL, CrmConsentStatus.GRANTED, CrmLegalBasis.CONSENT, member.getValueString("source"), member.getValueString("consentText"), member.getValueInteger("formSubmissionId"));
|
||||
store.update("crm_contact", new QRecord().withValue("id", member.getValueInteger("contactId")).withValue("unsubscribeDate", null));
|
||||
store.update("crm_email_list_member", new QRecord().withValue("id", claims.id()).withValue("status", CrmListMemberStatus.SUBSCRIBED.getId())
|
||||
.withValue("confirmedDate", Instant.now()).withValue("subscribedDate", Instant.now()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply a signed list-level or contact-level unsubscribe.
|
||||
*******************************************************************************/
|
||||
public void unsubscribe(String token) throws QException
|
||||
{
|
||||
CrmTokens.Claims claims = new CrmTokens(config).verify(token, "unsubscribe");
|
||||
unsubscribeByContact(claims.id(), claims.listId(), "Signed unsubscribe link");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Withdraw consent and mark the selected memberships. Null listId also suppresses the contact globally.
|
||||
*******************************************************************************/
|
||||
public void unsubscribeByContact(Integer contactId, Integer listId, String source) throws QException
|
||||
{
|
||||
store.get("crm_contact", contactId);
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId));
|
||||
if(listId != null)
|
||||
{
|
||||
store.get("crm_email_list", listId);
|
||||
filter.addCriteria(new QFilterCriteria("emailListId", QCriteriaOperator.EQUALS, listId));
|
||||
}
|
||||
for(QRecord member : store.query("crm_email_list_member", filter))
|
||||
{
|
||||
store.update("crm_email_list_member", new QRecord().withValue("id", member.getValueInteger("id"))
|
||||
.withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()).withValue("unsubscribedDate", Instant.now()).withValue("confirmationTokenHash", null));
|
||||
}
|
||||
if(listId == null)
|
||||
{
|
||||
store.update("crm_contact", new QRecord().withValue("id", contactId).withValue("unsubscribeDate", Instant.now()));
|
||||
}
|
||||
consent(contactId, CrmConsentType.EMAIL, CrmConsentStatus.WITHDRAWN, CrmLegalBasis.CONSENT, source, "Email consent withdrawn", null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate before any writes, including the caller's pre-hashed pseudonymous identifiers.
|
||||
*******************************************************************************/
|
||||
private void validate(CrmFormRequest request) throws QUserFacingException
|
||||
{
|
||||
if(request == null || request.formKey() == null || request.formKey().isBlank() || request.formType() == null
|
||||
|| request.email() == null || !normalize(request.email()).matches("[^@\\s]+@[^@\\s]+"))
|
||||
{
|
||||
throw (new QUserFacingException("A form key, form type and valid email address are required."));
|
||||
}
|
||||
if(request.marketingConsent() && (request.consentText() == null || request.consentText().isBlank()))
|
||||
{
|
||||
throw (new QUserFacingException("Marketing consent requires the exact wording shown to the visitor."));
|
||||
}
|
||||
for(String hash : new String[] {request.visitorKey(), request.clientHash()})
|
||||
{
|
||||
if(hash != null && !hash.matches("[a-fA-F0-9]{64}"))
|
||||
{
|
||||
throw (new QUserFacingException("Visitor and client identifiers must be SHA-256 hashes, never raw identifiers."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve email without case sensitivity using the selected storage mode.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> contacts(String email) throws QException
|
||||
{
|
||||
return (store.query("crm_contact", new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, normalize(email))
|
||||
.withOption(CriteriaOption.CASE_INSENSITIVE))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a lead if needed, then preserve the first touch and refresh last-touch attribution only when supplied.
|
||||
*******************************************************************************/
|
||||
private QRecord contact(CrmFormRequest request, List<QRecord> contacts) throws QException
|
||||
{
|
||||
QRecord contact;
|
||||
if(contacts.isEmpty())
|
||||
{
|
||||
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource();
|
||||
List<QRecord> sources = store.by("crm_lead_source", "name", source);
|
||||
QRecord leadSource = sources.isEmpty() ? store.insert("crm_lead_source", new QRecord().withValue("name", source).withValue("sortOrder", 0)) : sources.get(0);
|
||||
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
|
||||
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
|
||||
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
|
||||
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
|
||||
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
|
||||
}
|
||||
else
|
||||
{
|
||||
contact = contacts.get(0);
|
||||
}
|
||||
QRecord update = new QRecord().withValue("id", contact.getValueInteger("id"));
|
||||
String[] suffixes = {"UtmSource", "UtmMedium", "UtmCampaign", "UtmContent", "UtmTerm", "LandingPage", "Referrer"};
|
||||
String[] values = {request.utmSource(), request.utmMedium(), request.utmCampaign(), request.utmContent(), request.utmTerm(), request.landingPage(), request.referrer()};
|
||||
boolean attributed = Arrays.stream(values, 0, 6).anyMatch(value -> value != null && !value.isBlank());
|
||||
boolean first = contact.getValue("firstTouchDate") == null;
|
||||
for(int i = 0; i < suffixes.length; i++)
|
||||
{
|
||||
if(first && contact.getValue("firstTouch" + suffixes[i]) == null)
|
||||
{
|
||||
update.withValue("firstTouch" + suffixes[i], values[i]);
|
||||
}
|
||||
if(attributed)
|
||||
{
|
||||
update.withValue("lastTouch" + suffixes[i], values[i]);
|
||||
}
|
||||
}
|
||||
if(first)
|
||||
{
|
||||
update.withValue("firstTouchDate", Instant.now());
|
||||
}
|
||||
update.withValue("lastTouchDate", Instant.now());
|
||||
store.update("crm_contact", update);
|
||||
if(contacts.isEmpty() && isUnassigned(contact.getValueString("ownerUserId")))
|
||||
{
|
||||
CrmAutoAssign.assign(CrmAssignableEntity.CONTACT, contact.getValueInteger("id"));
|
||||
}
|
||||
return (store.get("crm_contact", contact.getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist exact submission evidence without raw network identifiers.
|
||||
*******************************************************************************/
|
||||
private QRecord submission(CrmFormRequest request, Integer contactId) throws QException
|
||||
{
|
||||
CrmFormType formType;
|
||||
switch(request.formType())
|
||||
{
|
||||
case CONTACT -> formType = CrmFormType.CONTACT;
|
||||
case DEMO -> formType = CrmFormType.DEMO_REQUEST;
|
||||
case NEWSLETTER -> formType = CrmFormType.NEWSLETTER;
|
||||
default -> formType = CrmFormType.GENERAL;
|
||||
}
|
||||
return (store.insert("crm_form_submission", new QRecord().withValue("formKey", request.formKey()).withValue("formType", formType.getId())
|
||||
.withValue("email", normalize(request.email())).withValue("firstName", request.firstName()).withValue("lastName", request.lastName())
|
||||
.withValue("phone", request.phone()).withValue("company", request.company()).withValue("message", request.message())
|
||||
.withValue("marketingConsent", request.marketingConsent()).withValue("consentText", request.consentText())
|
||||
.withValue("utmSource", request.utmSource()).withValue("utmMedium", request.utmMedium()).withValue("utmCampaign", request.utmCampaign())
|
||||
.withValue("utmContent", request.utmContent()).withValue("utmTerm", request.utmTerm()).withValue("landingPage", request.landingPage())
|
||||
.withValue("referrer", request.referrer()).withValue("visitorKey", request.visitorKey()).withValue("clientHash", request.clientHash())
|
||||
.withValue("contactId", contactId).withValue("conversionStatus", CrmFormConversionStatus.CONVERTED_TO_CONTACT.getId()).withValue("convertedDate", Instant.now())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Append consent evidence. Existing records remain immutable.
|
||||
*******************************************************************************/
|
||||
private void consent(Integer contactId, CrmConsentType type, CrmConsentStatus status, CrmLegalBasis basis, String source, String wording, Integer submissionId) throws QException
|
||||
{
|
||||
store.insert("crm_consent_record", new QRecord().withValue("contactId", contactId).withValue("consentType", type.getId())
|
||||
.withValue("status", status.getId()).withValue("legalBasis", basis.getId()).withValue("source", source).withValue("consentText", wording)
|
||||
.withValue("evidenceFormSubmissionId", submissionId).withValue("consentDate", Instant.now()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core signals accept one audience; publish the same lead to each intended audience.
|
||||
*******************************************************************************/
|
||||
private void notifyLead(QRecord submission, NotificationAudience audience)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("New web lead received").withSourceName("crm.lead.received")
|
||||
.withSourceKey("crm.lead.received:" + submission.getValueInteger("id"))
|
||||
.withSourceTableName("crm_form_submission").withSourceRecordId(submission.getValueString("id"))
|
||||
.withAudience(audience));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** System and anonymous placeholders do not represent a sales owner.
|
||||
*******************************************************************************/
|
||||
private boolean isUnassigned(String owner)
|
||||
{
|
||||
return (owner == null || owner.isBlank() || "system".equalsIgnoreCase(owner) || "anonymous".equalsIgnoreCase(owner));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize email for new writes.
|
||||
*******************************************************************************/
|
||||
private String normalize(String email)
|
||||
{
|
||||
return (email.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing CRM metadata requires both name fields; retain an honest missing-name label.
|
||||
*******************************************************************************/
|
||||
private String name(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? "Not provided" : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforces caller permissions because marketing runs for a signed-in user or worker.
|
||||
** Supported marketing contract. Only eligible recipients expose addresses; all other responses contain identifiers or reason codes.
|
||||
*******************************************************************************/
|
||||
public class CrmMarketingApi
|
||||
{
|
||||
private final CrmStore store = CrmStore.asUser();
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current application contains the CRM contract tables.
|
||||
*******************************************************************************/
|
||||
public boolean isAvailable()
|
||||
{
|
||||
return (CrmApi.isInstalled(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve crm-list:id or crm-contact:id with fresh suppression checks.
|
||||
*******************************************************************************/
|
||||
public List<CrmRecipient> resolveRecipients(String scope) throws QException
|
||||
{
|
||||
List<CrmRecipient> result = new ArrayList<>();
|
||||
if(scope == null)
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
String[] parts = scope.split(":", -1);
|
||||
if(parts.length != 2 || !("crm-list".equals(parts[0]) || "crm-contact".equals(parts[0])))
|
||||
{
|
||||
throw (new QUserFacingException("Use a CRM recipient scope: crm-list:<id> or crm-contact:<id>."));
|
||||
}
|
||||
Integer id;
|
||||
try
|
||||
{
|
||||
id = Integer.valueOf(parts[1]);
|
||||
if(id <= 0)
|
||||
{
|
||||
throw (new NumberFormatException());
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QUserFacingException("A CRM recipient scope must end in a positive numeric identifier."));
|
||||
}
|
||||
if("crm-contact".equals(parts[0]))
|
||||
{
|
||||
result.add(recipient(id, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord list = store.get("crm_email_list", id);
|
||||
for(QRecord member : store.by("crm_email_list_member", "emailListId", id))
|
||||
{
|
||||
Integer contactId = member.getValueInteger("contactId");
|
||||
if(contactId != null)
|
||||
{
|
||||
String reason = !Boolean.TRUE.equals(list.getValueBoolean("isActive")) ? "list_inactive"
|
||||
: CrmListMemberStatus.SUBSCRIBED.getId().equals(member.getValueInteger("status")) ? null : "list_member_not_subscribed";
|
||||
result.add(recipient(contactId, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest Email consent decides; suppression always wins. No consent means no marketing email.
|
||||
*******************************************************************************/
|
||||
public Eligibility checkEmailEligibility(Integer contactId) throws QException
|
||||
{
|
||||
List<QRecord> contacts = contactId == null ? List.of() : store.by("crm_contact", "id", contactId);
|
||||
if(contacts.isEmpty())
|
||||
{
|
||||
return (no("contact_not_found"));
|
||||
}
|
||||
QRecord contact = contacts.get(0);
|
||||
if(Boolean.TRUE.equals(contact.getValueBoolean("doNotEmail")))
|
||||
{
|
||||
return (no("do_not_email"));
|
||||
}
|
||||
if(contact.getValue("unsubscribeDate") != null)
|
||||
{
|
||||
return (no("unsubscribed"));
|
||||
}
|
||||
if(contact.getValueString("email") == null || contact.getValueString("email").isBlank())
|
||||
{
|
||||
return (no("no_email_address"));
|
||||
}
|
||||
List<QRecord> consents = store.query("crm_consent_record", new QQueryFilter(
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId), new QFilterCriteria("consentType", QCriteriaOperator.EQUALS, CrmConsentType.EMAIL.getId())));
|
||||
if(consents.isEmpty())
|
||||
{
|
||||
return (no("no_email_consent"));
|
||||
}
|
||||
QRecord latest = consents.stream().max(Comparator.comparing((QRecord row) -> row.getValueInstant("consentDate") == null
|
||||
? Instant.EPOCH : row.getValueInstant("consentDate")).thenComparing(row -> row.getValueInteger("id"))).orElseThrow();
|
||||
if(!CrmConsentStatus.GRANTED.getId().equals(latest.getValueInteger("status")))
|
||||
{
|
||||
return (no("email_consent_withdrawn"));
|
||||
}
|
||||
Instant expiry = latest.getValueInstant("expiryDate");
|
||||
if(expiry != null && !expiry.isAfter(Instant.now()))
|
||||
{
|
||||
return (no("email_consent_expired"));
|
||||
}
|
||||
return (new Eligibility(true, "eligible"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Campaign references contain no form contents or addresses.
|
||||
*******************************************************************************/
|
||||
public List<CrmFormSubmissionRef> listFormSubmissions(String utmCampaign, Instant since) throws QException
|
||||
{
|
||||
if(utmCampaign == null || utmCampaign.isBlank())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("utmCampaign", QCriteriaOperator.EQUALS, utmCampaign));
|
||||
if(since != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since));
|
||||
}
|
||||
return (store.query("crm_form_submission", filter).stream().map(row -> new CrmFormSubmissionRef(row.getValueInteger("id"),
|
||||
row.getValueInteger("contactId"), row.getValueInstant("createDate"))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Log a typed interaction through the normal activity table actions.
|
||||
*******************************************************************************/
|
||||
public void recordActivity(Integer contactId, ActivityKind kind, String subject, String description) throws QException
|
||||
{
|
||||
store.recordActivity(contactId, kind, subject, description);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Convenience overload for the existing marketing gateway's Note activity contract.
|
||||
*******************************************************************************/
|
||||
public void recordActivity(Integer contactId, String subject, String description) throws QException
|
||||
{
|
||||
recordActivity(contactId, ActivityKind.NOTE, subject, description);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record an integration-originated withdrawal of email consent.
|
||||
*******************************************************************************/
|
||||
public void recordUnsubscribe(Integer contactId, Integer listId, String source) throws QException
|
||||
{
|
||||
new CrmIntakeApi(CrmApi.config(), null, store).unsubscribeByContact(contactId, listId, source);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a ninety-day signed link without returning any personal data.
|
||||
*******************************************************************************/
|
||||
public String buildUnsubscribeUrl(Integer contactId, Integer listId) throws QException
|
||||
{
|
||||
store.get("crm_contact", contactId);
|
||||
if(listId != null)
|
||||
{
|
||||
store.get("crm_email_list", listId);
|
||||
}
|
||||
CrmTokens tokens = new CrmTokens(CrmApi.config());
|
||||
return (tokens.url("unsubscribe", tokens.issue("unsubscribe", contactId, listId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Offer only active lists visible to this caller.
|
||||
*******************************************************************************/
|
||||
public List<RecipientScopeOption> recipientScopeOptions() throws QException
|
||||
{
|
||||
return (store.by("crm_email_list", "isActive", true).stream()
|
||||
.map(row -> new RecipientScopeOption("crm-list:" + row.getValueInteger("id"), row.getValueString("name"))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch an address only after eligibility is established.
|
||||
*******************************************************************************/
|
||||
private CrmRecipient recipient(Integer contactId, String reason) throws QException
|
||||
{
|
||||
Eligibility eligibility = reason == null ? checkEmailEligibility(contactId) : no(reason);
|
||||
return (new CrmRecipient(contactId, eligibility.eligible() ? store.get("crm_contact", contactId).getValueString("email") : null, eligibility));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a suppression result without personal data.
|
||||
*******************************************************************************/
|
||||
private Eligibility no(String reason)
|
||||
{
|
||||
return (new Eligibility(false, reason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Available email delivery path; configuration is not a delivery guarantee.
|
||||
*******************************************************************************/
|
||||
public enum CrmReadiness
|
||||
{
|
||||
NOT_CONFIGURED,
|
||||
QBIT_EMAIL,
|
||||
CORE_MESSAGING
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient address is present only when marketing email is eligible.
|
||||
*******************************************************************************/
|
||||
public record CrmRecipient(Integer contactId, String address, Eligibility eligibility)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable recipient key for marketing executions; never use an address as an id.
|
||||
*******************************************************************************/
|
||||
public String key()
|
||||
{
|
||||
return ("crm-contact:" + contactId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent activity-code initialization through normal table actions, never producer side effects.
|
||||
*******************************************************************************/
|
||||
public final class CrmStarterData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No instances.
|
||||
*******************************************************************************/
|
||||
private CrmStarterData()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Find or insert each supported activity code in the current instance.
|
||||
*******************************************************************************/
|
||||
public static void ensure() throws QException
|
||||
{
|
||||
ensure(CrmStore.asUser());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Initialize codes using the initiating operation's permission mode.
|
||||
*******************************************************************************/
|
||||
static void ensure(CrmStore store) throws QException
|
||||
{
|
||||
for(ActivityKind kind : ActivityKind.values())
|
||||
{
|
||||
if(!store.by("crm_activity_type", "code", kind.name()).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String label = kind.name().substring(0, 1) + kind.name().substring(1).toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
List<QRecord> named = store.by("crm_activity_type", "name", label);
|
||||
if(!named.isEmpty() && named.get(0).getValue("code") == null)
|
||||
{
|
||||
store.update("crm_activity_type", new QRecord().withValue("id", named.get(0).getValue("id")).withValue("code", kind.name()));
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
store.insert("crm_activity_type", new QRecord().withValue("name", label).withValue("code", kind.name())
|
||||
.withValue("sortOrder", kind.ordinal()).withValue("isSystem", true));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(store.by("crm_activity_type", "code", kind.name()).isEmpty())
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal table-action boundary. Explicitly selects trusted host work or caller permission enforcement.
|
||||
*******************************************************************************/
|
||||
final class CrmStore
|
||||
{
|
||||
private final PermissionEnforcement permissionEnforcement;
|
||||
|
||||
/*******************************************************************************
|
||||
** Select permission enforcement for all actions in this store.
|
||||
*******************************************************************************/
|
||||
private CrmStore(PermissionEnforcement permissionEnforcement)
|
||||
{
|
||||
this.permissionEnforcement = permissionEnforcement;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validated host intake is internal work, independent of the visitor's permissions.
|
||||
*******************************************************************************/
|
||||
static CrmStore trusted()
|
||||
{
|
||||
return (new CrmStore(PermissionEnforcement.SKIP));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed-in users and marketing workers must hold the permissions for their work.
|
||||
*******************************************************************************/
|
||||
static CrmStore asUser()
|
||||
{
|
||||
return (new CrmStore(PermissionEnforcement.ENFORCE));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core requires an explicit reason when selecting SKIP for internal work.
|
||||
*******************************************************************************/
|
||||
private void applyPermissions(AbstractActionInput input)
|
||||
{
|
||||
if(permissionEnforcement == PermissionEnforcement.SKIP)
|
||||
{
|
||||
input.withPermissionBypass("CRM intake is trusted host work after public request validation");
|
||||
}
|
||||
else
|
||||
{
|
||||
input.withPermissionEnforcement(permissionEnforcement);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query through core actions.
|
||||
*******************************************************************************/
|
||||
List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter);
|
||||
applyPermissions(input);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query a single field.
|
||||
*******************************************************************************/
|
||||
List<QRecord> by(String table, String field, Serializable value) throws QException
|
||||
{
|
||||
return (query(table, new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retrieve a visible record or fail without disclosing its contents.
|
||||
*******************************************************************************/
|
||||
QRecord get(String table, Integer id) throws QException
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
throw (new QUserFacingException("A record identifier is required."));
|
||||
}
|
||||
List<QRecord> rows = by(table, "id", id);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("The CRM record is unavailable."));
|
||||
}
|
||||
return (rows.get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert and surface record-level validation failures.
|
||||
*******************************************************************************/
|
||||
QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(table).withRecord(record);
|
||||
applyPermissions(input);
|
||||
return (checked(new InsertAction().execute(input).getRecords()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Update without bypassing customizers, audit or locks.
|
||||
*******************************************************************************/
|
||||
QRecord update(String table, QRecord record) throws QException
|
||||
{
|
||||
UpdateInput input = new UpdateInput(table).withRecords(List.of(record));
|
||||
applyPermissions(input);
|
||||
return (checked(new UpdateAction().execute(input).getRecords()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record an interaction with the same permission mode as the initiating operation.
|
||||
*******************************************************************************/
|
||||
void recordActivity(Integer contactId, ActivityKind kind, String subject, String description) throws QException
|
||||
{
|
||||
get("crm_contact", contactId);
|
||||
if(kind == null || subject == null || subject.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("An activity kind and subject are required."));
|
||||
}
|
||||
CrmStarterData.ensure(this);
|
||||
Integer typeId = by("crm_activity_type", "code", kind.name()).get(0).getValueInteger("id");
|
||||
insert("crm_activity", new QRecord().withValue("contactId", contactId).withValue("activityTypeId", typeId)
|
||||
.withValue("subject", subject).withValue("description", description).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
|
||||
.withValue("isCompleted", true).withValue("completedDate", Instant.now()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never return success when the action refused a record.
|
||||
*******************************************************************************/
|
||||
private static QRecord checked(List<QRecord> records) throws QException
|
||||
{
|
||||
if(records.isEmpty() || (records.get(0).getErrors() != null && !records.get(0).getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QUserFacingException("CRM could not save the record. Check required fields and permissions."));
|
||||
}
|
||||
return (records.get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit mailing-list signup and the exact wording accepted by the visitor.
|
||||
*******************************************************************************/
|
||||
public record CrmSubscribeRequest(String listKey, String email, String firstName, String lastName, String consentText, String source,
|
||||
String utmSource, String utmMedium, String utmCampaign, String utmContent, String utmTerm, String visitorKey, String clientHash)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of a mailing-list signup.
|
||||
*******************************************************************************/
|
||||
public record CrmSubscribeResult(SubscriptionStatus status)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email eligibility with a stable reason code and no personal data.
|
||||
*******************************************************************************/
|
||||
public record Eligibility(boolean eligible, String reason)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported intake form kinds.
|
||||
*******************************************************************************/
|
||||
public enum FormType
|
||||
{
|
||||
CONTACT,
|
||||
NEWSLETTER,
|
||||
DEMO,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An active list offered as a marketing recipient scope.
|
||||
*******************************************************************************/
|
||||
public record RecipientScopeOption(String scope, String label)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mailing-list subscription outcomes.
|
||||
*******************************************************************************/
|
||||
public enum SubscriptionStatus
|
||||
{
|
||||
PENDING_CONFIRMATION,
|
||||
SUBSCRIBED,
|
||||
ALREADY_SUBSCRIBED
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
@@ -81,7 +83,7 @@ public class ConsentRecord extends QRecordEntity
|
||||
.withRecordLabelFormat("Consent %s - %s")
|
||||
.withRecordLabelFields("contactId", "consentType")
|
||||
.withSection(SectionFactory.defaultT1("id", "contactId", "consentType", "status"))
|
||||
.withSection(SectionFactory.defaultT2("legalBasis", "source", "consentDate", "expiryDate", "ipAddress"))
|
||||
.withSection(SectionFactory.defaultT2("legalBasis", "source", "consentDate", "expiryDate", "consentText", "evidenceFormSubmissionId"))
|
||||
.withSection(SectionFactory.defaultT3("createDate"));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
@@ -90,6 +92,8 @@ public class ConsentRecord extends QRecordEntity
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(PreventUpdateCustomizer.class));
|
||||
table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(PreventDeleteCustomizer.class));
|
||||
|
||||
table.getField("consentText").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
@@ -130,6 +134,12 @@ public class ConsentRecord extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(possibleValueSourceName = FormSubmission.TABLE_NAME)
|
||||
private Integer evidenceFormSubmissionId;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -154,9 +164,6 @@ public class ConsentRecord extends QRecordEntity
|
||||
@QField()
|
||||
private Instant expiryDate;
|
||||
|
||||
@QField(maxLength = 45, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String ipAddress;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -429,37 +436,6 @@ public class ConsentRecord extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ipAddress
|
||||
*******************************************************************************/
|
||||
public String getIpAddress()
|
||||
{
|
||||
return (this.ipAddress);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public void setIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public ConsentRecord withIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
@@ -489,4 +465,61 @@ public class ConsentRecord extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public ConsentRecord withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for evidenceFormSubmissionId
|
||||
*******************************************************************************/
|
||||
public Integer getEvidenceFormSubmissionId()
|
||||
{
|
||||
return (this.evidenceFormSubmissionId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for evidenceFormSubmissionId
|
||||
*******************************************************************************/
|
||||
public void setEvidenceFormSubmissionId(Integer evidenceFormSubmissionId)
|
||||
{
|
||||
this.evidenceFormSubmissionId = evidenceFormSubmissionId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for evidenceFormSubmissionId
|
||||
*******************************************************************************/
|
||||
public ConsentRecord withEvidenceFormSubmissionId(Integer evidenceFormSubmissionId)
|
||||
{
|
||||
this.evidenceFormSubmissionId = evidenceFormSubmissionId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -107,6 +107,8 @@ public class Contact extends QRecordEntity
|
||||
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
|
||||
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
|
||||
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
|
||||
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
|
||||
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
|
||||
|
||||
@@ -141,6 +143,54 @@ public class Contact extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmContent;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmTerm;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchLandingPage;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchReferrer;
|
||||
|
||||
@QField()
|
||||
private Instant firstTouchDate;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmContent;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmTerm;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchLandingPage;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchReferrer;
|
||||
|
||||
@QField()
|
||||
private Instant lastTouchDate;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -1248,4 +1298,453 @@ public class Contact extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmSource()
|
||||
{
|
||||
return (this.firstTouchUtmSource);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmSource(String firstTouchUtmSource)
|
||||
{
|
||||
this.firstTouchUtmSource = firstTouchUtmSource;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmSource(String firstTouchUtmSource)
|
||||
{
|
||||
this.firstTouchUtmSource = firstTouchUtmSource;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmMedium()
|
||||
{
|
||||
return (this.firstTouchUtmMedium);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmMedium(String firstTouchUtmMedium)
|
||||
{
|
||||
this.firstTouchUtmMedium = firstTouchUtmMedium;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmMedium(String firstTouchUtmMedium)
|
||||
{
|
||||
this.firstTouchUtmMedium = firstTouchUtmMedium;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmCampaign()
|
||||
{
|
||||
return (this.firstTouchUtmCampaign);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmCampaign(String firstTouchUtmCampaign)
|
||||
{
|
||||
this.firstTouchUtmCampaign = firstTouchUtmCampaign;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmCampaign(String firstTouchUtmCampaign)
|
||||
{
|
||||
this.firstTouchUtmCampaign = firstTouchUtmCampaign;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmContent()
|
||||
{
|
||||
return (this.firstTouchUtmContent);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmContent(String firstTouchUtmContent)
|
||||
{
|
||||
this.firstTouchUtmContent = firstTouchUtmContent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmContent(String firstTouchUtmContent)
|
||||
{
|
||||
this.firstTouchUtmContent = firstTouchUtmContent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmTerm()
|
||||
{
|
||||
return (this.firstTouchUtmTerm);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmTerm(String firstTouchUtmTerm)
|
||||
{
|
||||
this.firstTouchUtmTerm = firstTouchUtmTerm;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmTerm(String firstTouchUtmTerm)
|
||||
{
|
||||
this.firstTouchUtmTerm = firstTouchUtmTerm;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchLandingPage()
|
||||
{
|
||||
return (this.firstTouchLandingPage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchLandingPage(String firstTouchLandingPage)
|
||||
{
|
||||
this.firstTouchLandingPage = firstTouchLandingPage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchLandingPage(String firstTouchLandingPage)
|
||||
{
|
||||
this.firstTouchLandingPage = firstTouchLandingPage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchReferrer
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchReferrer()
|
||||
{
|
||||
return (this.firstTouchReferrer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchReferrer
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchReferrer(String firstTouchReferrer)
|
||||
{
|
||||
this.firstTouchReferrer = firstTouchReferrer;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchReferrer
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchReferrer(String firstTouchReferrer)
|
||||
{
|
||||
this.firstTouchReferrer = firstTouchReferrer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchDate
|
||||
*******************************************************************************/
|
||||
public Instant getFirstTouchDate()
|
||||
{
|
||||
return (this.firstTouchDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchDate
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchDate(Instant firstTouchDate)
|
||||
{
|
||||
this.firstTouchDate = firstTouchDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchDate
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchDate(Instant firstTouchDate)
|
||||
{
|
||||
this.firstTouchDate = firstTouchDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmSource()
|
||||
{
|
||||
return (this.lastTouchUtmSource);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmSource(String lastTouchUtmSource)
|
||||
{
|
||||
this.lastTouchUtmSource = lastTouchUtmSource;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmSource(String lastTouchUtmSource)
|
||||
{
|
||||
this.lastTouchUtmSource = lastTouchUtmSource;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmMedium()
|
||||
{
|
||||
return (this.lastTouchUtmMedium);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmMedium(String lastTouchUtmMedium)
|
||||
{
|
||||
this.lastTouchUtmMedium = lastTouchUtmMedium;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmMedium(String lastTouchUtmMedium)
|
||||
{
|
||||
this.lastTouchUtmMedium = lastTouchUtmMedium;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmCampaign()
|
||||
{
|
||||
return (this.lastTouchUtmCampaign);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmCampaign(String lastTouchUtmCampaign)
|
||||
{
|
||||
this.lastTouchUtmCampaign = lastTouchUtmCampaign;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmCampaign(String lastTouchUtmCampaign)
|
||||
{
|
||||
this.lastTouchUtmCampaign = lastTouchUtmCampaign;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmContent()
|
||||
{
|
||||
return (this.lastTouchUtmContent);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmContent(String lastTouchUtmContent)
|
||||
{
|
||||
this.lastTouchUtmContent = lastTouchUtmContent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmContent(String lastTouchUtmContent)
|
||||
{
|
||||
this.lastTouchUtmContent = lastTouchUtmContent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmTerm()
|
||||
{
|
||||
return (this.lastTouchUtmTerm);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmTerm(String lastTouchUtmTerm)
|
||||
{
|
||||
this.lastTouchUtmTerm = lastTouchUtmTerm;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmTerm(String lastTouchUtmTerm)
|
||||
{
|
||||
this.lastTouchUtmTerm = lastTouchUtmTerm;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public String getLastTouchLandingPage()
|
||||
{
|
||||
return (this.lastTouchLandingPage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public void setLastTouchLandingPage(String lastTouchLandingPage)
|
||||
{
|
||||
this.lastTouchLandingPage = lastTouchLandingPage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchLandingPage(String lastTouchLandingPage)
|
||||
{
|
||||
this.lastTouchLandingPage = lastTouchLandingPage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchReferrer
|
||||
*******************************************************************************/
|
||||
public String getLastTouchReferrer()
|
||||
{
|
||||
return (this.lastTouchReferrer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchReferrer
|
||||
*******************************************************************************/
|
||||
public void setLastTouchReferrer(String lastTouchReferrer)
|
||||
{
|
||||
this.lastTouchReferrer = lastTouchReferrer;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchReferrer
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchReferrer(String lastTouchReferrer)
|
||||
{
|
||||
this.lastTouchReferrer = lastTouchReferrer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchDate
|
||||
*******************************************************************************/
|
||||
public Instant getLastTouchDate()
|
||||
{
|
||||
return (this.lastTouchDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchDate
|
||||
*******************************************************************************/
|
||||
public void setLastTouchDate(Instant lastTouchDate)
|
||||
{
|
||||
this.lastTouchDate = lastTouchDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchDate
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchDate(Instant lastTouchDate)
|
||||
{
|
||||
this.lastTouchDate = lastTouchDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -74,8 +74,9 @@ public class EmailList extends QRecordEntity
|
||||
.withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withUniqueKey(new UniqueKey("slug"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "slug", "isActive", "isPublic", "sortOrder"))
|
||||
.withSection(SectionFactory.defaultT2("description", "memberCount"))
|
||||
.withUniqueKey(new UniqueKey("listKey"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "listKey", "slug", "isActive", "isPublic", "sortOrder"))
|
||||
.withSection(SectionFactory.defaultT2("description", "memberCount", "requireDoubleOptIn", "consentText"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
return (table);
|
||||
@@ -84,6 +85,15 @@ public class EmailList extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String listKey;
|
||||
|
||||
@QField(isRequired = true, defaultValue = "true")
|
||||
private Boolean requireDoubleOptIn;
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -443,4 +453,89 @@ public class EmailList extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for listKey
|
||||
*******************************************************************************/
|
||||
public String getListKey()
|
||||
{
|
||||
return (this.listKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for listKey
|
||||
*******************************************************************************/
|
||||
public void setListKey(String listKey)
|
||||
{
|
||||
this.listKey = listKey;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for listKey
|
||||
*******************************************************************************/
|
||||
public EmailList withListKey(String listKey)
|
||||
{
|
||||
this.listKey = listKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requireDoubleOptIn
|
||||
*******************************************************************************/
|
||||
public Boolean getRequireDoubleOptIn()
|
||||
{
|
||||
return (this.requireDoubleOptIn);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for requireDoubleOptIn
|
||||
*******************************************************************************/
|
||||
public void setRequireDoubleOptIn(Boolean requireDoubleOptIn)
|
||||
{
|
||||
this.requireDoubleOptIn = requireDoubleOptIn;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requireDoubleOptIn
|
||||
*******************************************************************************/
|
||||
public EmailList withRequireDoubleOptIn(Boolean requireDoubleOptIn)
|
||||
{
|
||||
this.requireDoubleOptIn = requireDoubleOptIn;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public EmailList withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,6 +28,8 @@ package com.kingsrook.qbits.crm.core.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
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;
|
||||
@@ -77,7 +79,7 @@ public class EmailListMember extends QRecordEntity
|
||||
.withRecordLabelFields("emailListId", "email")
|
||||
.withUniqueKey(new UniqueKey("emailListId", "email"))
|
||||
.withSection(SectionFactory.defaultT1("id", "emailListId", "contactId", "email", "firstName", "status"))
|
||||
.withSection(SectionFactory.defaultT2("subscribedDate", "unsubscribedDate", "subscriptionSource", "formSubmissionId"))
|
||||
.withSection(SectionFactory.defaultT2("subscribedDate", "unsubscribedDate", "subscriptionSource", "formSubmissionId", "source", "consentText", "confirmationTokenHash", "confirmationSentDate", "confirmedDate"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
@@ -86,12 +88,32 @@ public class EmailListMember extends QRecordEntity
|
||||
table.getField("formSubmissionId").withPossibleValueSourceFilter(new QQueryFilter(
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, "${input.contactId}")));
|
||||
|
||||
table.getField("email").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("firstName").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("consentText").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("confirmationTokenHash").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String confirmationTokenHash;
|
||||
|
||||
@QField()
|
||||
private Instant confirmationSentDate;
|
||||
|
||||
@QField()
|
||||
private Instant confirmedDate;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String source;
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -110,7 +132,7 @@ public class EmailListMember extends QRecordEntity
|
||||
@QField(isRequired = true, possibleValueSourceName = CrmListMemberStatus.NAME, defaultValue = "1")
|
||||
private Integer status;
|
||||
|
||||
@QField(isRequired = true)
|
||||
@QField()
|
||||
private Instant subscribedDate;
|
||||
|
||||
@QField()
|
||||
@@ -519,4 +541,145 @@ public class EmailListMember extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmationTokenHash
|
||||
*******************************************************************************/
|
||||
public String getConfirmationTokenHash()
|
||||
{
|
||||
return (this.confirmationTokenHash);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for confirmationTokenHash
|
||||
*******************************************************************************/
|
||||
public void setConfirmationTokenHash(String confirmationTokenHash)
|
||||
{
|
||||
this.confirmationTokenHash = confirmationTokenHash;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmationTokenHash
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConfirmationTokenHash(String confirmationTokenHash)
|
||||
{
|
||||
this.confirmationTokenHash = confirmationTokenHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmationSentDate
|
||||
*******************************************************************************/
|
||||
public Instant getConfirmationSentDate()
|
||||
{
|
||||
return (this.confirmationSentDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for confirmationSentDate
|
||||
*******************************************************************************/
|
||||
public void setConfirmationSentDate(Instant confirmationSentDate)
|
||||
{
|
||||
this.confirmationSentDate = confirmationSentDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmationSentDate
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConfirmationSentDate(Instant confirmationSentDate)
|
||||
{
|
||||
this.confirmationSentDate = confirmationSentDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmedDate
|
||||
*******************************************************************************/
|
||||
public Instant getConfirmedDate()
|
||||
{
|
||||
return (this.confirmedDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for confirmedDate
|
||||
*******************************************************************************/
|
||||
public void setConfirmedDate(Instant confirmedDate)
|
||||
{
|
||||
this.confirmedDate = confirmedDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmedDate
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConfirmedDate(Instant confirmedDate)
|
||||
{
|
||||
this.confirmedDate = confirmedDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for source
|
||||
*******************************************************************************/
|
||||
public String getSource()
|
||||
{
|
||||
return (this.source);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for source
|
||||
*******************************************************************************/
|
||||
public void setSource(String source)
|
||||
{
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for source
|
||||
*******************************************************************************/
|
||||
public EmailListMember withSource(String source)
|
||||
{
|
||||
this.source = source;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -47,7 +49,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for FormSubmission table -- raw website form submissions.
|
||||
** Captures web-specific metadata (UTM, referrer, IP) that does not belong on
|
||||
** Captures web-specific metadata (UTM, referrer, hashed client identifiers) that does not belong on
|
||||
** crm_contact. Submissions are converted to contacts via a process.
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
@@ -77,22 +79,50 @@ public class FormSubmission extends QRecordEntity
|
||||
.withIcon(new QIcon().withName("dynamic_form"))
|
||||
.withRecordLabelFormat("%s - %s %s")
|
||||
.withRecordLabelFields("formType", "firstName", "email")
|
||||
.withSection(SectionFactory.defaultT1("id", "formType", "email", "firstName", "lastName"))
|
||||
.withSection(SectionFactory.defaultT2("company", "phone", "message"))
|
||||
.withSection(SectionFactory.defaultT1("id", "formKey", "formType", "email", "firstName", "lastName"))
|
||||
.withSection(SectionFactory.defaultT2("company", "phone", "message", "marketingConsent", "consentText"))
|
||||
.withSection(new QFieldSection("utm", new QIcon().withName("campaign"), Tier.T2,
|
||||
List.of("sourceUrl", "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", "referrer")))
|
||||
List.of("sourceUrl", "landingPage", "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", "referrer")))
|
||||
.withSection(new QFieldSection("conversion", new QIcon().withName("swap_horiz"), Tier.T2,
|
||||
List.of("contactId", "conversionStatus", "convertedDate", "convertedByUserId")))
|
||||
.withSection(new QFieldSection("technical", new QIcon().withName("code"), Tier.T2,
|
||||
List.of("ipAddress", "userAgent", "metadata")))
|
||||
List.of("visitorKey", "clientHash", "metadata")))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
table.getField("firstName").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("lastName").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("email").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("phone").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("company").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("message").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("consentText").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("visitorKey").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("clientHash").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String formKey;
|
||||
|
||||
@QField(defaultValue = "false")
|
||||
private Boolean marketingConsent;
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String landingPage;
|
||||
|
||||
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String visitorKey;
|
||||
|
||||
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String clientHash;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -150,12 +180,6 @@ public class FormSubmission extends QRecordEntity
|
||||
@QField()
|
||||
private String convertedByUserId;
|
||||
|
||||
@QField(maxLength = 45, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String ipAddress;
|
||||
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String userAgent;
|
||||
|
||||
@QField()
|
||||
private String metadata;
|
||||
|
||||
@@ -775,68 +799,6 @@ public class FormSubmission extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ipAddress
|
||||
*******************************************************************************/
|
||||
public String getIpAddress()
|
||||
{
|
||||
return (this.ipAddress);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public void setIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public FormSubmission withIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userAgent
|
||||
*******************************************************************************/
|
||||
public String getUserAgent()
|
||||
{
|
||||
return (this.userAgent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userAgent
|
||||
*******************************************************************************/
|
||||
public void setUserAgent(String userAgent)
|
||||
{
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userAgent
|
||||
*******************************************************************************/
|
||||
public FormSubmission withUserAgent(String userAgent)
|
||||
{
|
||||
this.userAgent = userAgent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metadata
|
||||
*******************************************************************************/
|
||||
@@ -928,4 +890,173 @@ public class FormSubmission extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for formKey
|
||||
*******************************************************************************/
|
||||
public String getFormKey()
|
||||
{
|
||||
return (this.formKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for formKey
|
||||
*******************************************************************************/
|
||||
public void setFormKey(String formKey)
|
||||
{
|
||||
this.formKey = formKey;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for formKey
|
||||
*******************************************************************************/
|
||||
public FormSubmission withFormKey(String formKey)
|
||||
{
|
||||
this.formKey = formKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for marketingConsent
|
||||
*******************************************************************************/
|
||||
public Boolean getMarketingConsent()
|
||||
{
|
||||
return (this.marketingConsent);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for marketingConsent
|
||||
*******************************************************************************/
|
||||
public void setMarketingConsent(Boolean marketingConsent)
|
||||
{
|
||||
this.marketingConsent = marketingConsent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for marketingConsent
|
||||
*******************************************************************************/
|
||||
public FormSubmission withMarketingConsent(Boolean marketingConsent)
|
||||
{
|
||||
this.marketingConsent = marketingConsent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public FormSubmission withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for landingPage
|
||||
*******************************************************************************/
|
||||
public String getLandingPage()
|
||||
{
|
||||
return (this.landingPage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for landingPage
|
||||
*******************************************************************************/
|
||||
public void setLandingPage(String landingPage)
|
||||
{
|
||||
this.landingPage = landingPage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for landingPage
|
||||
*******************************************************************************/
|
||||
public FormSubmission withLandingPage(String landingPage)
|
||||
{
|
||||
this.landingPage = landingPage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for visitorKey
|
||||
*******************************************************************************/
|
||||
public String getVisitorKey()
|
||||
{
|
||||
return (this.visitorKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for visitorKey
|
||||
*******************************************************************************/
|
||||
public void setVisitorKey(String visitorKey)
|
||||
{
|
||||
this.visitorKey = visitorKey;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for visitorKey
|
||||
*******************************************************************************/
|
||||
public FormSubmission withVisitorKey(String visitorKey)
|
||||
{
|
||||
this.visitorKey = visitorKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientHash
|
||||
*******************************************************************************/
|
||||
public String getClientHash()
|
||||
{
|
||||
return (this.clientHash);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientHash
|
||||
*******************************************************************************/
|
||||
public void setClientHash(String clientHash)
|
||||
{
|
||||
this.clientHash = clientHash;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientHash
|
||||
*******************************************************************************/
|
||||
public FormSubmission withClientHash(String clientHash)
|
||||
{
|
||||
this.clientHash = clientHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -40,7 +40,8 @@ public enum CrmConsentType implements PossibleValueEnum<Integer>
|
||||
EMAIL(1, "Email"),
|
||||
CALL(2, "Call"),
|
||||
SMS(3, "SMS"),
|
||||
DATA_PROCESSING(4, "Data Processing");
|
||||
DATA_PROCESSING(4, "Data Processing"),
|
||||
REPLY_TO_INQUIRY(5, "Reply to inquiry");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
@@ -40,7 +40,8 @@ public enum CrmListMemberStatus implements PossibleValueEnum<Integer>
|
||||
SUBSCRIBED(1, "Subscribed"),
|
||||
UNSUBSCRIBED(2, "Unsubscribed"),
|
||||
BOUNCED(3, "Bounced"),
|
||||
CLEANED(4, "Cleaned");
|
||||
CLEANED(4, "Cleaned"),
|
||||
PENDING_CONFIRMATION(5, "Pending confirmation");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiEffectOutcome;
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only CRM adapter that imports the optional qbit-email public API; otherwise delegates to core messaging.
|
||||
*******************************************************************************/
|
||||
public class CrmEmailTransport
|
||||
{
|
||||
private final CrmQBitConfig config;
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the transport for this instance. Subclass send for a host/test transport.
|
||||
*******************************************************************************/
|
||||
public CrmEmailTransport(CrmQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check class presence before linking the optional public API.
|
||||
*******************************************************************************/
|
||||
private boolean emailInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
|
||||
return (EmailApi.isInstalled(QContext.getQInstance()));
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report a usable configured transport. An installed but unready email qbit is not bypassed.
|
||||
*******************************************************************************/
|
||||
public CrmReadiness readiness() throws QException
|
||||
{
|
||||
if(emailInstalled())
|
||||
{
|
||||
return (EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING).ready()
|
||||
? CrmReadiness.QBIT_EMAIL : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
return (config.getMessagingProviderName() != null && QContext.getQInstance().getMessagingProvider(config.getMessagingProviderName()) != null
|
||||
? CrmReadiness.CORE_MESSAGING : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
{
|
||||
if(emailInstalled())
|
||||
{
|
||||
String key = "crm:" + purpose + ":" + id;
|
||||
var result = EmailApi.sendOnce(new EmailApiRequest().withTo(address).withSubject(subject).withMarkdownBody(body)
|
||||
.withCategory(marketing ? EmailApiCategory.MARKETING : EmailApiCategory.TRANSACTIONAL)
|
||||
.withLinkedRecord("crm_contact", contactId).withSender("crm", purpose).withIdempotencyKey(key), key);
|
||||
if(result.outcome() == EmailApiEffectOutcome.REFUSED)
|
||||
{
|
||||
throw (new QUserFacingException("The email service could not accept this message."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(readiness() != CrmReadiness.CORE_MESSAGING)
|
||||
{
|
||||
throw (new QUserFacingException("CRM email is not configured. Install qbit-email or configure a messaging provider."));
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRule;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRuleMember;
|
||||
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.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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal assignment work shared by intake and the permission-gated UI process.
|
||||
** Evaluates active rules in sort order and preserves the first matching strategy.
|
||||
*******************************************************************************/
|
||||
public final class CrmAutoAssign
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CrmAutoAssign.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmAutoAssign()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assignment outcome retained for the existing process output contract.
|
||||
*******************************************************************************/
|
||||
public record Result(String action, String assignedUserId, Integer ruleId)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assign as trusted internal work after the caller validates its entry point.
|
||||
*******************************************************************************/
|
||||
public static Result assign(CrmAssignableEntity entityType, Integer recordId) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// resolve table name //
|
||||
///////////////////////////////////////////////
|
||||
String tableName = entityType == CrmAssignableEntity.CONTACT
|
||||
? Contact.TABLE_NAME : Deal.TABLE_NAME;
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// load the record //
|
||||
///////////////////////////////////////////////
|
||||
GetInput getInput = new GetInput(tableName).withPrimaryKey(recordId);
|
||||
getInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
GetOutput recordGet = new GetAction().execute(getInput);
|
||||
|
||||
if(recordGet.getRecord() == null)
|
||||
{
|
||||
return (new Result("RECORD_NOT_FOUND", null, null));
|
||||
}
|
||||
|
||||
QRecord record = recordGet.getRecord();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query active rules for this entity type //
|
||||
///////////////////////////////////////////////
|
||||
QueryInput rulesInput = new QueryInput(AssignmentRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, entityType.getId()))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")));
|
||||
rulesInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
QueryOutput rulesQuery = new QueryAction().execute(rulesInput);
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// evaluate rules, first match wins //
|
||||
///////////////////////////////////////////////
|
||||
for(QRecord ruleRecord : rulesQuery.getRecords())
|
||||
{
|
||||
AssignmentRule rule = new AssignmentRule(ruleRecord);
|
||||
|
||||
if(evaluateCriteria(rule, record))
|
||||
{
|
||||
String assignedUserId = resolveAssignment(rule);
|
||||
|
||||
if(StringUtils.hasContent(assignedUserId))
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", recordId)
|
||||
.withValue("ownerUserId", assignedUserId);
|
||||
|
||||
UpdateInput updateInput = new UpdateInput(tableName).withRecord(updateRecord);
|
||||
updateInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
new UpdateAction().execute(updateInput);
|
||||
|
||||
return (new Result("ASSIGNED", assignedUserId, rule.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// no rules matched -- keep existing owner //
|
||||
///////////////////////////////////////////////
|
||||
return (new Result("NO_MATCH", null, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Evaluate a rule's criteria against a record. If criteriaJson is null
|
||||
** or empty, the rule matches all records.
|
||||
***************************************************************************/
|
||||
private static boolean evaluateCriteria(AssignmentRule rule, QRecord record)
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// null or empty criteria matches all //
|
||||
/////////////////////////////////////////////
|
||||
if(!StringUtils.hasContent(rule.getCriteriaJson()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// parse criteria JSON using JsonUtils //
|
||||
// format: {"fieldName":"expectedValue"} //
|
||||
/////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class);
|
||||
for(Map.Entry<String, Object> entry : criteriaMap.entrySet())
|
||||
{
|
||||
String fieldName = entry.getKey();
|
||||
String expectedValue = entry.getValue() == null ? null : String.valueOf(entry.getValue());
|
||||
String actualValue = record.getValueString(fieldName);
|
||||
|
||||
if(!Objects.equals(actualValue, expectedValue))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (true);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error parsing criteriaJson for assignment rule", e);
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the user to assign to based on the rule's strategy.
|
||||
***************************************************************************/
|
||||
private static String resolveAssignment(AssignmentRule rule) throws QException
|
||||
{
|
||||
CrmAssignStrategy strategy = CrmAssignStrategy.getById(rule.getAssignStrategy());
|
||||
|
||||
if(strategy == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
switch(strategy)
|
||||
{
|
||||
case SPECIFIC_USER:
|
||||
return (rule.getAssignToUserId());
|
||||
|
||||
case ROUND_ROBIN:
|
||||
return (resolveRoundRobin(rule));
|
||||
|
||||
case LEAST_ACTIVE:
|
||||
return (resolveLeastActive(rule));
|
||||
|
||||
default:
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Round robin: find active members, pick the one after the most recently
|
||||
** assigned (by sortOrder), wrapping around.
|
||||
***************************************************************************/
|
||||
private static String resolveRoundRobin(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// find the last assigned member //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember lastAssigned = null;
|
||||
for(AssignmentRuleMember member : members)
|
||||
{
|
||||
if(member.getLastAssignedDate() != null)
|
||||
{
|
||||
if(lastAssigned == null || member.getLastAssignedDate().isAfter(lastAssigned.getLastAssignedDate()))
|
||||
{
|
||||
lastAssigned = member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// pick next member after last assigned //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember selected;
|
||||
if(lastAssigned == null)
|
||||
{
|
||||
selected = members.get(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
int lastIndex = members.indexOf(lastAssigned);
|
||||
int nextIndex = (lastIndex + 1) % members.size();
|
||||
selected = members.get(nextIndex);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// update lastAssignedDate //
|
||||
/////////////////////////////////////////////
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Least active: find active members, pick the one with fewest owned
|
||||
** records (approximated by oldest lastAssignedDate or null).
|
||||
***************************************************************************/
|
||||
private static String resolveLeastActive(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// pick member with null lastAssignedDate, or oldest date //
|
||||
/////////////////////////////////////////////////////////////
|
||||
AssignmentRuleMember selected = members.stream()
|
||||
.min(Comparator.comparing(
|
||||
m -> m.getLastAssignedDate() != null ? m.getLastAssignedDate() : Instant.EPOCH))
|
||||
.orElse(members.get(0));
|
||||
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Query active members for a rule, ordered by sortOrder.
|
||||
***************************************************************************/
|
||||
private static List<AssignmentRuleMember> getActiveMembers(Integer ruleId) throws QException
|
||||
{
|
||||
QueryInput membersInput = new QueryInput(AssignmentRuleMember.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("assignmentRuleId", QCriteriaOperator.EQUALS, ruleId))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")));
|
||||
membersInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
QueryOutput membersQuery = new QueryAction().execute(membersInput);
|
||||
|
||||
return (membersQuery.getRecords().stream()
|
||||
.map(AssignmentRuleMember::new)
|
||||
.toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Update the lastAssignedDate on a member.
|
||||
***************************************************************************/
|
||||
private static void updateLastAssignedDate(AssignmentRuleMember member) throws QException
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", member.getId())
|
||||
.withValue("lastAssignedDate", Instant.now());
|
||||
|
||||
UpdateInput updateInput = new UpdateInput(AssignmentRuleMember.TABLE_NAME).withRecord(updateRecord);
|
||||
updateInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
new UpdateAction().execute(updateInput);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,35 +26,13 @@
|
||||
package com.kingsrook.qbits.crm.scoring.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRule;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRuleMember;
|
||||
import com.kingsrook.qbits.crm.scoring.CrmAutoAssign;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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;
|
||||
@@ -64,8 +42,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -75,8 +51,6 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AutoAssignProcess.class);
|
||||
|
||||
public static final String NAME = "crm_auto_assign";
|
||||
|
||||
|
||||
@@ -132,258 +106,12 @@ public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// resolve table name //
|
||||
///////////////////////////////////////////////
|
||||
String tableName = entityType == CrmAssignableEntity.CONTACT
|
||||
? Contact.TABLE_NAME : Deal.TABLE_NAME;
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// load the record //
|
||||
///////////////////////////////////////////////
|
||||
GetOutput recordGet = new GetAction().execute(
|
||||
new GetInput(tableName).withPrimaryKey(recordId));
|
||||
|
||||
if(recordGet.getRecord() == null)
|
||||
CrmAutoAssign.Result result = CrmAutoAssign.assign(entityType, recordId);
|
||||
output.addValue("action", result.action());
|
||||
if(result.assignedUserId() != null)
|
||||
{
|
||||
output.addValue("action", "RECORD_NOT_FOUND");
|
||||
return;
|
||||
}
|
||||
|
||||
QRecord record = recordGet.getRecord();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query active rules for this entity type //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput rulesQuery = new QueryAction().execute(
|
||||
new QueryInput(AssignmentRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, entityType.getPossibleValueId()))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder"))));
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// evaluate rules, first match wins //
|
||||
///////////////////////////////////////////////
|
||||
for(QRecord ruleRecord : rulesQuery.getRecords())
|
||||
{
|
||||
AssignmentRule rule = new AssignmentRule(ruleRecord);
|
||||
|
||||
if(evaluateCriteria(rule, record))
|
||||
{
|
||||
String assignedUserId = resolveAssignment(rule);
|
||||
|
||||
if(StringUtils.hasContent(assignedUserId))
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", recordId)
|
||||
.withValue("ownerUserId", assignedUserId);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(tableName).withRecord(updateRecord));
|
||||
|
||||
output.addValue("action", "ASSIGNED");
|
||||
output.addValue("assignedUserId", assignedUserId);
|
||||
output.addValue("ruleId", rule.getId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// no rules matched -- keep existing owner //
|
||||
///////////////////////////////////////////////
|
||||
output.addValue("action", "NO_MATCH");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Evaluate a rule's criteria against a record. If criteriaJson is null
|
||||
** or empty, the rule matches all records.
|
||||
***************************************************************************/
|
||||
private boolean evaluateCriteria(AssignmentRule rule, QRecord record)
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// null or empty criteria matches all //
|
||||
/////////////////////////////////////////////
|
||||
if(!StringUtils.hasContent(rule.getCriteriaJson()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// parse criteria JSON using JsonUtils //
|
||||
// format: {"fieldName":"expectedValue"} //
|
||||
/////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class);
|
||||
for(Map.Entry<String, Object> entry : criteriaMap.entrySet())
|
||||
{
|
||||
String fieldName = entry.getKey();
|
||||
String expectedValue = entry.getValue() == null ? null : String.valueOf(entry.getValue());
|
||||
String actualValue = record.getValueString(fieldName);
|
||||
|
||||
if(!Objects.equals(actualValue, expectedValue))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (true);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error parsing criteriaJson for assignment rule", e);
|
||||
return (false);
|
||||
output.addValue("assignedUserId", result.assignedUserId());
|
||||
output.addValue("ruleId", result.ruleId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the user to assign to based on the rule's strategy.
|
||||
***************************************************************************/
|
||||
private String resolveAssignment(AssignmentRule rule) throws QException
|
||||
{
|
||||
CrmAssignStrategy strategy = CrmAssignStrategy.getById(rule.getAssignStrategy());
|
||||
|
||||
if(strategy == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
switch(strategy)
|
||||
{
|
||||
case SPECIFIC_USER:
|
||||
return (rule.getAssignToUserId());
|
||||
|
||||
case ROUND_ROBIN:
|
||||
return (resolveRoundRobin(rule));
|
||||
|
||||
case LEAST_ACTIVE:
|
||||
return (resolveLeastActive(rule));
|
||||
|
||||
default:
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Round robin: find active members, pick the one after the most recently
|
||||
** assigned (by sortOrder), wrapping around.
|
||||
***************************************************************************/
|
||||
private String resolveRoundRobin(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// find the last assigned member //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember lastAssigned = null;
|
||||
for(AssignmentRuleMember member : members)
|
||||
{
|
||||
if(member.getLastAssignedDate() != null)
|
||||
{
|
||||
if(lastAssigned == null || member.getLastAssignedDate().isAfter(lastAssigned.getLastAssignedDate()))
|
||||
{
|
||||
lastAssigned = member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// pick next member after last assigned //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember selected;
|
||||
if(lastAssigned == null)
|
||||
{
|
||||
selected = members.get(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
int lastIndex = members.indexOf(lastAssigned);
|
||||
int nextIndex = (lastIndex + 1) % members.size();
|
||||
selected = members.get(nextIndex);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// update lastAssignedDate //
|
||||
/////////////////////////////////////////////
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Least active: find active members, pick the one with fewest owned
|
||||
** records (approximated by oldest lastAssignedDate or null).
|
||||
***************************************************************************/
|
||||
private String resolveLeastActive(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// pick member with null lastAssignedDate, or oldest date //
|
||||
/////////////////////////////////////////////////////////////
|
||||
AssignmentRuleMember selected = members.stream()
|
||||
.min(Comparator.comparing(
|
||||
m -> m.getLastAssignedDate() != null ? m.getLastAssignedDate() : Instant.EPOCH))
|
||||
.orElse(members.get(0));
|
||||
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Query active members for a rule, ordered by sortOrder.
|
||||
***************************************************************************/
|
||||
private List<AssignmentRuleMember> getActiveMembers(Integer ruleId) throws QException
|
||||
{
|
||||
QueryOutput membersQuery = new QueryAction().execute(
|
||||
new QueryInput(AssignmentRuleMember.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("assignmentRuleId", QCriteriaOperator.EQUALS, ruleId))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder"))));
|
||||
|
||||
return (membersQuery.getRecords().stream()
|
||||
.map(AssignmentRuleMember::new)
|
||||
.toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Update the lastAssignedDate on a member.
|
||||
***************************************************************************/
|
||||
private void updateLastAssignedDate(AssignmentRuleMember member) throws QException
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", member.getId())
|
||||
.withValue("lastAssignedDate", Instant.now());
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(AssignmentRuleMember.TABLE_NAME).withRecord(updateRecord));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.security;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Purpose-bound HMAC-SHA256 links. Payloads contain identifiers, expiry and a nonce, never personal data.
|
||||
*******************************************************************************/
|
||||
public class CrmTokens
|
||||
{
|
||||
private final CrmQBitConfig config;
|
||||
private final Clock clock;
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the current UTC clock.
|
||||
*******************************************************************************/
|
||||
public CrmTokens(CrmQBitConfig config) throws QUserFacingException
|
||||
{
|
||||
this(config, Clock.systemUTC());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit clock permits deterministic expiry verification.
|
||||
*******************************************************************************/
|
||||
public CrmTokens(CrmQBitConfig config, Clock clock) throws QUserFacingException
|
||||
{
|
||||
if(config.getTokenSecret() == null || config.getTokenSecret().isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("CRM intake requires CrmQBitConfig.withTokenSecret(String)."));
|
||||
}
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue a link valid for ninety days. Confirm ids identify members; unsubscribe ids identify contacts.
|
||||
*******************************************************************************/
|
||||
public String issue(String purpose, Integer id, Integer listId) throws QUserFacingException
|
||||
{
|
||||
if(!("confirm".equals(purpose) || "unsubscribe".equals(purpose)) || id == null || id <= 0
|
||||
|| (listId != null && listId <= 0) || ("confirm".equals(purpose) && listId == null))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
String payload = purpose + ":" + id + ":" + (listId == null ? "" : listId) + ":"
|
||||
+ clock.instant().plus(Duration.ofDays(90)).getEpochSecond() + ":" + UUID.randomUUID();
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return (encoded + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sign(encoded)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify signature in constant time before parsing purpose or identifiers.
|
||||
*******************************************************************************/
|
||||
public Claims verify(String token, String purpose) throws QUserFacingException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(token == null || token.length() > 1024)
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
String[] parts = token.split("\\.", -1);
|
||||
if(parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64.getUrlDecoder().decode(parts[1])))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
String[] values = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8).split(":", -1);
|
||||
if(values.length != 5 || !purpose.equals(values[0]))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
Integer id = Integer.valueOf(values[1]);
|
||||
Integer listId = values[2].isEmpty() ? null : Integer.valueOf(values[2]);
|
||||
Instant expiry = Instant.ofEpochSecond(Long.parseLong(values[3]));
|
||||
if(id <= 0 || (listId != null && listId <= 0) || ("confirm".equals(purpose) && listId == null) || !expiry.isAfter(clock.instant()))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
return (new Claims(purpose, id, listId, expiry));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hash the entire confirmation token before persistence.
|
||||
*******************************************************************************/
|
||||
public static String hash(String token)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a link from the host's explicitly configured public origin/path.
|
||||
*******************************************************************************/
|
||||
public String url(String purpose, String token) throws QUserFacingException
|
||||
{
|
||||
String base = config.getPublicBaseUrl();
|
||||
if(base == null || !base.matches("https?://[^\\s?#]+"))
|
||||
{
|
||||
throw (new QUserFacingException("CRM links require CrmQBitConfig.withPublicBaseUrl(String)."));
|
||||
}
|
||||
return (base.replaceAll("/+$", "") + "/qqq/hooks/crm/" + purpose + "/" + token);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compute an authentication code without exposing the secret.
|
||||
*******************************************************************************/
|
||||
private byte[] sign(String payload) throws QUserFacingException
|
||||
{
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(config.getTokenSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return (mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(java.security.GeneralSecurityException e)
|
||||
{
|
||||
throw (new QUserFacingException("CRM token signing is unavailable."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately does not reveal the token or validation detail.
|
||||
*******************************************************************************/
|
||||
private static QUserFacingException invalid()
|
||||
{
|
||||
return (new QUserFacingException("This link is invalid or has expired. Please request a new link."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated token payload.
|
||||
*******************************************************************************/
|
||||
public record Claims(String purpose, Integer id, Integer listId, Instant expiresAt)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.webhook;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmIntakeApi;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
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.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed machine routes outside the UI API. GET never mutates records, and pages never display personal data or tokens.
|
||||
*******************************************************************************/
|
||||
public class CrmHookRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private QInstance instance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Register once when the host has Javalin middleware.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance);
|
||||
if(CollectionUtils.nonNullList(metadata.getAdditionalRouteProviderReferences()).stream()
|
||||
.noneMatch(reference -> reference != null && CrmHookRouteProvider.class.getName().equals(reference.getName())))
|
||||
{
|
||||
metadata.withAdditionalRouteProviderReference(new QCodeReference(CrmHookRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get("/qqq/hooks/crm/confirm/{token}", context -> handle(context, "confirm", false));
|
||||
config.routes.post("/qqq/hooks/crm/confirm/{token}", context -> handle(context, "confirm", true));
|
||||
config.routes.get("/qqq/hooks/crm/unsubscribe/{token}", context -> handle(context, "unsubscribe", false));
|
||||
config.routes.post("/qqq/hooks/crm/unsubscribe/{token}", context -> handle(context, "unsubscribe", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The signed token authorizes only the identified operation; restore the caller context afterward.
|
||||
*******************************************************************************/
|
||||
private void handle(Context context, String purpose, boolean post)
|
||||
{
|
||||
CapturedContext previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer")
|
||||
.header("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'");
|
||||
String token = context.pathParam("token");
|
||||
new CrmTokens(CrmApi.config()).verify(token, purpose);
|
||||
if(post)
|
||||
{
|
||||
CrmIntakeApi api = new CrmIntakeApi();
|
||||
if("confirm".equals(purpose))
|
||||
{
|
||||
api.confirm(token);
|
||||
}
|
||||
else
|
||||
{
|
||||
api.unsubscribe(token);
|
||||
}
|
||||
page(context, 200, "Preference saved", "Your email preference has been saved.", null);
|
||||
}
|
||||
else
|
||||
{
|
||||
String label = "confirm".equals(purpose) ? "Confirm subscription" : "Unsubscribe";
|
||||
page(context, 200, label, "Choose the button below to save your email preference.", label);
|
||||
}
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
page(context, 400, "Link unavailable", "This link is invalid or has expired. Please request a new link.", null);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
page(context, 503, "Please try again", "We could not save your preference. Please try again later.", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All content is fixed text; the browser posts to its current URL without embedding the token.
|
||||
*******************************************************************************/
|
||||
private void page(Context context, int status, String title, String message, String button)
|
||||
{
|
||||
context.status(status).contentType("text/html; charset=utf-8").result("<!doctype html><html lang=\"en\"><head>"
|
||||
+ "<meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>" + title
|
||||
+ "</title></head><body><main><h1>" + title + "</h1><p>" + message + "</p>"
|
||||
+ (button == null ? "" : "<form method=\"post\"><button type=\"submit\">" + button + "</button></form>")
|
||||
+ "</main></body></html>");
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_activity_participant_user_id ON crm_activity_p
|
||||
|
||||
--changeset qbit-crm:020-crm_activity_type
|
||||
CREATE TABLE IF NOT EXISTS crm_activity_type (
|
||||
code VARCHAR(50) UNIQUE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
icon_name VARCHAR(50),
|
||||
@@ -153,6 +154,8 @@ CREATE INDEX IF NOT EXISTS ix_crm_company_parent_company_id ON crm_company (pare
|
||||
|
||||
--changeset qbit-crm:020-crm_consent_record
|
||||
CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
consent_text TEXT,
|
||||
evidence_form_submission_id INTEGER,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
contact_id INTEGER NOT NULL,
|
||||
consent_type INTEGER NOT NULL,
|
||||
@@ -161,7 +164,6 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
source VARCHAR(250),
|
||||
consent_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
expiry_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
@@ -169,6 +171,22 @@ CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_recor
|
||||
|
||||
--changeset qbit-crm:020-crm_contact
|
||||
CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
first_touch_utm_source VARCHAR(250),
|
||||
first_touch_utm_medium VARCHAR(250),
|
||||
first_touch_utm_campaign VARCHAR(250),
|
||||
first_touch_utm_content VARCHAR(250),
|
||||
first_touch_utm_term VARCHAR(250),
|
||||
first_touch_landing_page VARCHAR(1000),
|
||||
first_touch_referrer VARCHAR(1000),
|
||||
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_touch_utm_source VARCHAR(250),
|
||||
last_touch_utm_medium VARCHAR(250),
|
||||
last_touch_utm_campaign VARCHAR(250),
|
||||
last_touch_utm_content VARCHAR(250),
|
||||
last_touch_utm_term VARCHAR(250),
|
||||
last_touch_landing_page VARCHAR(1000),
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
@@ -317,6 +335,9 @@ CREATE INDEX IF NOT EXISTS ix_crm_deal_stage_history_user_id ON crm_deal_stage_h
|
||||
|
||||
--changeset qbit-crm:020-crm_email_list
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
list_key VARCHAR(250) NOT NULL UNIQUE,
|
||||
require_double_opt_in BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
@@ -333,13 +354,18 @@ CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
|
||||
--changeset qbit-crm:020-crm_email_list_member
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list_member (
|
||||
confirmation_token_hash VARCHAR(64),
|
||||
confirmation_sent_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
source VARCHAR(250),
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email_list_id INTEGER NOT NULL,
|
||||
contact_id INTEGER,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
status INTEGER DEFAULT 1 NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
unsubscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
subscription_source VARCHAR(250),
|
||||
form_submission_id INTEGER,
|
||||
@@ -440,6 +466,12 @@ CREATE INDEX IF NOT EXISTS ix_crm_external_mapping_external_id ON crm_external_m
|
||||
|
||||
--changeset qbit-crm:020-crm_form_submission
|
||||
CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
form_key VARCHAR(250) NOT NULL,
|
||||
marketing_consent BOOLEAN DEFAULT FALSE,
|
||||
consent_text TEXT,
|
||||
landing_page VARCHAR(1000),
|
||||
visitor_key VARCHAR(64),
|
||||
client_hash VARCHAR(64),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
form_type INTEGER NOT NULL,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
@@ -459,8 +491,6 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
conversion_status INTEGER DEFAULT 1 NOT NULL,
|
||||
converted_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
converted_by_user_id TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
metadata TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
@@ -638,3 +668,5 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT uq_crm_win_loss_reason_1 UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
@@ -125,7 +125,7 @@ class ContingentPossibleValuesTest extends BaseTest
|
||||
{
|
||||
return new QRecord().withValue("id", id).withValue(parent, scope)
|
||||
.withValue("name", "Choice " + id).withValue("firstName", "Contact " + id)
|
||||
.withValue("email", "contact" + id + "@example.com").withValue("formType", 1);
|
||||
.withValue("email", "contact" + id + "@example.com").withValue("formKey", "test-form").withValue("formType", 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class CrmIntegrationTest extends BaseTest
|
||||
new InsertInput(FormSubmission.TABLE_NAME)
|
||||
.withRecordEntity(new FormSubmission()
|
||||
.withEmail("john@example.com")
|
||||
.withFormType(CrmFormType.CONTACT.getPossibleValueId())
|
||||
.withFormKey("test-form").withFormType(CrmFormType.CONTACT.getPossibleValueId())
|
||||
.withFirstName("John")));
|
||||
Integer formId = formInsert.getRecords().get(0).getValueInteger("id");
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmFormRequest;
|
||||
import com.kingsrook.qbits.crm.api.CrmIntakeApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
|
||||
import com.kingsrook.qbits.crm.api.FormType;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -140,9 +145,9 @@ class CrmRdbmsTest
|
||||
Integer activityType = insert("crm_activity_type", new QRecord().withValue("name", "Call").withValue("sortOrder", 1));
|
||||
Integer activity = insert("crm_activity", new QRecord().withValue("activityTypeId", activityType).withValue("subject", "Discovery call")
|
||||
.withValue("ownerUserId", "rep").withValue("contactId", contact).withValue("companyId", company).withValue("dealId", deal));
|
||||
Integer submission = insert("crm_form_submission", new QRecord().withValue("formType", 1).withValue("email", "ada@example.test")
|
||||
Integer submission = insert("crm_form_submission", new QRecord().withValue("formKey", "test-form").withValue("formType", 1).withValue("email", "ada@example.test")
|
||||
.withValue("firstName", "Ada").withValue("lastName", "Lovelace"));
|
||||
Integer list = insert("crm_email_list", new QRecord().withValue("name", "Newsletter").withValue("slug", "newsletter").withValue("sortOrder", 1));
|
||||
Integer list = insert("crm_email_list", new QRecord().withValue("name", "Newsletter").withValue("listKey", "newsletter").withValue("slug", "newsletter").withValue("sortOrder", 1));
|
||||
Integer member = insert("crm_email_list_member", new QRecord().withValue("emailListId", list).withValue("contactId", contact)
|
||||
.withValue("email", "ada@example.test").withValue("subscribedDate", Instant.parse("2026-10-14T12:00:00Z")).withValue("formSubmissionId", submission));
|
||||
Integer consent = insert("crm_consent_record", new QRecord().withValue("contactId", contact).withValue("consentType", 1)
|
||||
@@ -169,7 +174,50 @@ class CrmRdbmsTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All 395 columns, metadata unique keys and relationship indexes exist in SQL.
|
||||
** Intake persists attribution and immutable evidence against the shipped SQL schema.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testIntakeAndFreshInstallConstraints() throws Exception
|
||||
{
|
||||
CrmApi.config().withTokenSecret("h2-test-secret").withPublicBaseUrl("https://example.test");
|
||||
Integer contactId = insert("crm_contact", new QRecord().withValue("firstName", "Ada").withValue("lastName", "Lovelace")
|
||||
.withValue("email", "ADA@EXAMPLE.TEST").withValue("ownerUserId", "rep"));
|
||||
CrmIntakeApi intake = new CrmIntakeApi();
|
||||
var result = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, "Ada", "Lovelace", "ada@example.test",
|
||||
null, "Example", "Please reply", false, "Reply only", "search", "organic", "launch", "hero", "crm",
|
||||
"https://example.test/contact", "https://search.example.test", "a".repeat(64), "b".repeat(64)));
|
||||
assertThat(result.contactCreated()).isFalse();
|
||||
assertThat(result.contactId()).isEqualTo(contactId);
|
||||
assertThat(query("crm_contact", contactId).getValueString("firstTouchUtmContent")).isEqualTo("hero");
|
||||
assertThat(query("crm_contact", contactId).getValueString("lastTouchUtmTerm")).isEqualTo("crm");
|
||||
assertThat(query("crm_form_submission", result.submissionId()).getValueString("clientHash")).isEqualTo("b".repeat(64));
|
||||
var consents = new QueryAction().execute(new QueryInput("crm_consent_record")).getRecords();
|
||||
assertThat(consents).hasSize(1);
|
||||
assertThat(consents.get(0).getValueInteger("evidenceFormSubmissionId")).isEqualTo(result.submissionId());
|
||||
assertThat(consents.get(0).getValueString("consentText")).isEqualTo("Reply only");
|
||||
assertThat(new CrmMarketingApi().checkEmailEligibility(contactId).eligible()).isFalse();
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.executeUpdate("INSERT INTO crm_email_list (name, list_key, slug, sort_order) VALUES ('News', 'news', 'news', 1)");
|
||||
try(ResultSet rows = statement.executeQuery("SELECT require_double_opt_in FROM crm_email_list WHERE list_key = 'news'"))
|
||||
{
|
||||
assertThat(rows.next()).isTrue();
|
||||
assertThat(rows.getBoolean(1)).isTrue();
|
||||
}
|
||||
assertThat(assertThrows(SQLException.class, () -> statement.executeUpdate(
|
||||
"INSERT INTO crm_email_list (name, list_key, slug, sort_order) VALUES ('Other', 'news', 'other', 2)")).getSQLState()).isEqualTo("23505");
|
||||
assertThat(assertThrows(SQLException.class, () -> statement.executeUpdate(
|
||||
"INSERT INTO crm_activity_type (name, code, sort_order) VALUES ('Another note', 'NOTE', 2)")).getSQLState()).isEqualTo("23505");
|
||||
assertThat(assertThrows(SQLException.class, () -> statement.executeUpdate(
|
||||
"INSERT INTO crm_form_submission (form_type, email) VALUES (1, 'missing-key@example.test')")).getSQLState()).isEqualTo("23502");
|
||||
statement.executeUpdate("INSERT INTO crm_activity_type (name, sort_order) VALUES ('Custom A', 10), ('Custom B', 11)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All columns, metadata unique keys and relationship indexes exist in SQL.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSchemaMatchesMetadata() throws Exception
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
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.CrmFormConversionStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
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.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End-to-end API contract tests through core actions, covering consent, attribution and double opt-in.
|
||||
*******************************************************************************/
|
||||
public class CrmApisTest extends BaseTest
|
||||
{
|
||||
private CrmQBitConfig config;
|
||||
private FakeTransport transport;
|
||||
private CrmIntakeApi intake;
|
||||
private final CrmMarketingApi marketing = new CrmMarketingApi();
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup api.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setupApi() throws Exception
|
||||
{
|
||||
config = CrmApi.config().withTokenSecret("test-only-signing-secret").withPublicBaseUrl("https://crm.example.test");
|
||||
transport = new FakeTransport(config);
|
||||
intake = new CrmIntakeApi(config, transport);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Installation readiness and required configuration.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void installationReadinessAndRequiredConfiguration() throws Exception
|
||||
{
|
||||
assertThat(CrmApi.isInstalled(null)).isFalse();
|
||||
assertThat(CrmApi.isInstalled(new QInstance())).isFalse();
|
||||
assertThat(CrmApi.isInstalled(QContext.getQInstance())).isTrue();
|
||||
assertThat(marketing.isAvailable()).isTrue();
|
||||
assertThat(CrmApi.readiness()).isEqualTo(CrmReadiness.NOT_CONFIGURED);
|
||||
config.withMessagingProviderName("missing");
|
||||
assertThat(new CrmEmailTransport(config).readiness()).isEqualTo(CrmReadiness.NOT_CONFIGURED);
|
||||
assertThatThrownBy(() -> new CrmEmailTransport(config).send("one-to-one", "1", 1, "a@example.test", "Hello", "Body", false))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Form preserves first touch and never grants implicit marketing consent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void formPreservesFirstTouchAndNeverGrantsImplicitMarketingConsent() throws Exception
|
||||
{
|
||||
CrmStore.asUser().insert("crm_lifecycle_stage", new QRecord().withValue("name", "Lead").withValue("sortOrder", 1));
|
||||
CrmFormResult first = intake.submitForm(form("Ada@Example.test", false, "first"));
|
||||
assertThat(first.contactCreated()).isTrue();
|
||||
QRecord before = CrmStore.asUser().get("crm_contact", first.contactId());
|
||||
CrmFormResult last = intake.submitForm(form("ada@example.TEST", false, "second"));
|
||||
assertThat(last.contactCreated()).isFalse();
|
||||
assertThat(last.contactId()).isEqualTo(first.contactId());
|
||||
QRecord contact = CrmStore.asUser().get("crm_contact", first.contactId());
|
||||
assertThat(contact.getValueString("firstTouchUtmCampaign")).isEqualTo("first");
|
||||
assertThat(contact.getValueString("lastTouchUtmCampaign")).isEqualTo("second");
|
||||
assertThat(contact.getValueInstant("firstTouchDate")).isEqualTo(before.getValueInstant("firstTouchDate"));
|
||||
assertThat(contact.getValueInteger("lifecycleStageId")).isNotNull();
|
||||
assertThat(contact.getValueString("firstTouchLandingPage")).isEqualTo("https://example.test/form");
|
||||
assertThat(marketing.checkEmailEligibility(first.contactId()).reason()).isEqualTo("no_email_consent");
|
||||
QRecord consent = CrmStore.asUser().by("crm_consent_record", "contactId", first.contactId()).get(0);
|
||||
assertThat(consent.getValueInteger("consentType")).isEqualTo(5);
|
||||
assertThat(consent.getValueInteger("legalBasis")).isEqualTo(2);
|
||||
assertThat(consent.getValueInteger("evidenceFormSubmissionId")).isEqualTo(first.submissionId());
|
||||
assertThat(CrmStore.asUser().by("crm_activity", "contactId", first.contactId())).hasSize(2);
|
||||
CrmStarterData.ensure();
|
||||
assertThat(CrmStore.asUser().query("crm_activity_type", null)).hasSize(6);
|
||||
assertThat(marketing.listFormSubmissions("first", Instant.EPOCH)).containsExactly(
|
||||
new CrmFormSubmissionRef(first.submissionId(), first.contactId(), CrmStore.asUser().get("crm_form_submission", first.submissionId()).getValueInstant("createDate")));
|
||||
assertThat(marketing.listFormSubmissions("first", Instant.now().plusSeconds(60))).isEmpty();
|
||||
assertThat(marketing.listFormSubmissions(null, null)).isEmpty();
|
||||
assertThat(marketing.listFormSubmissions("", null)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit consent activities and scopes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void explicitConsentActivitiesAndScopes() throws Exception
|
||||
{
|
||||
CrmFormResult result = intake.submitForm(form("yes@example.test", true, "campaign"));
|
||||
assertThat(marketing.checkEmailEligibility(result.contactId()).eligible()).isTrue();
|
||||
assertThat(marketing.resolveRecipients("crm-contact:" + result.contactId())).containsExactly(
|
||||
new CrmRecipient(result.contactId(), "yes@example.test", new Eligibility(true, "eligible")));
|
||||
QRecord consent = CrmStore.asUser().by("crm_consent_record", "contactId", result.contactId()).get(0);
|
||||
assertThat(consent.getValueString("consentText")).isEqualTo("Send me news");
|
||||
assertThat(consent.getValueInteger("legalBasis")).isEqualTo(1);
|
||||
for(ActivityKind kind : ActivityKind.values())
|
||||
{
|
||||
marketing.recordActivity(result.contactId(), kind, "Interaction", "Recorded by an integration");
|
||||
}
|
||||
marketing.recordActivity(result.contactId(), "Gateway note", "Description");
|
||||
assertThat(CrmStore.asUser().by("crm_activity", "contactId", result.contactId())).hasSize(8);
|
||||
assertThatThrownBy(() -> marketing.recordActivity(result.contactId(), null, "", null)).isInstanceOf(QUserFacingException.class);
|
||||
assertThat(marketing.resolveRecipients(null)).isEmpty();
|
||||
for(String scope : List.of("other:1", "crm-list:x", "crm-list:-1", "crm-contact:1:2"))
|
||||
{
|
||||
assertThatThrownBy(() -> marketing.resolveRecipients(scope)).isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Eligibility matrix.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"none", "granted", "expired", "withdrawn", "doNotEmail", "unsubscribed", "pending", "bounced", "noAddress", "missing"})
|
||||
void eligibilityMatrix(String state) throws Exception
|
||||
{
|
||||
Integer id = intake.submitForm(form("matrix@example.test", false, "matrix")).contactId();
|
||||
if(!"none".equals(state))
|
||||
{
|
||||
CrmStore.asUser().insert("crm_consent_record", new QRecord().withValue("contactId", id).withValue("consentType", 1)
|
||||
.withValue("status", "withdrawn".equals(state) ? 2 : 1).withValue("consentDate", Instant.now())
|
||||
.withValue("expiryDate", "expired".equals(state) ? Instant.now().minusSeconds(1) : null));
|
||||
}
|
||||
String reason = "eligible";
|
||||
switch(state)
|
||||
{
|
||||
case "none" -> reason = "no_email_consent";
|
||||
case "expired" -> reason = "email_consent_expired";
|
||||
case "withdrawn" -> reason = "email_consent_withdrawn";
|
||||
case "doNotEmail" ->
|
||||
{
|
||||
CrmStore.asUser().update("crm_contact", new QRecord().withValue("id", id).withValue("doNotEmail", true));
|
||||
reason = "do_not_email";
|
||||
}
|
||||
case "unsubscribed" ->
|
||||
{
|
||||
CrmStore.asUser().update("crm_contact", new QRecord().withValue("id", id).withValue("unsubscribeDate", Instant.now()));
|
||||
reason = "unsubscribed";
|
||||
}
|
||||
case "pending", "bounced" ->
|
||||
{
|
||||
Integer listId = list("matrix", true);
|
||||
CrmStore.asUser().insert("crm_email_list_member", new QRecord().withValue("contactId", id).withValue("emailListId", listId)
|
||||
.withValue("email", "matrix@example.test").withValue("status", "pending".equals(state) ? 5 : 3));
|
||||
reason = "eligible";
|
||||
}
|
||||
case "noAddress" ->
|
||||
{
|
||||
CrmStore.asUser().update("crm_contact", new QRecord().withValue("id", id).withValue("email", null));
|
||||
reason = "no_email_address";
|
||||
}
|
||||
case "missing" ->
|
||||
{
|
||||
id = 9999;
|
||||
reason = "contact_not_found";
|
||||
}
|
||||
default -> reason = "eligible";
|
||||
}
|
||||
Eligibility eligibility = marketing.checkEmailEligibility(id);
|
||||
assertThat(eligibility.reason()).isEqualTo(reason);
|
||||
assertThat(eligibility.eligible()).isEqualTo(Set.of("granted", "pending", "bounced").contains(state));
|
||||
CrmRecipient recipient = marketing.resolveRecipients("crm-contact:" + id).get(0);
|
||||
assertThat(recipient.address()).isEqualTo(eligibility.eligible() ? "matrix@example.test" : null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Double opt in refresh confirm and unsubscribe end to end.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void doubleOptInRefreshConfirmAndUnsubscribeEndToEnd() throws Exception
|
||||
{
|
||||
Integer listId = list("news", true);
|
||||
CrmSubscribeRequest request = signup("news");
|
||||
assertThat(intake.subscribe(request).status()).isEqualTo(SubscriptionStatus.PENDING_CONFIRMATION);
|
||||
String oldToken = transport.token;
|
||||
QRecord member = CrmStore.asUser().query("crm_email_list_member", null).get(0);
|
||||
Integer contactId = member.getValueInteger("contactId");
|
||||
assertThat(member.getValue("subscribedDate")).isNull();
|
||||
assertThat(member.getValueString("confirmationTokenHash")).isEqualTo(CrmTokens.hash(oldToken)).isNotEqualTo(oldToken);
|
||||
assertThat(member.getValueString("consentText")).isEqualTo("Send me news");
|
||||
assertThat(CrmStore.asUser().by("crm_consent_record", "contactId", contactId)).isEmpty();
|
||||
assertThat(marketing.resolveRecipients("crm-list:" + listId).get(0).address()).isNull();
|
||||
intake.subscribe(request);
|
||||
assertThat(transport.token).isNotEqualTo(oldToken);
|
||||
assertThatThrownBy(() -> intake.confirm(oldToken)).isInstanceOf(QUserFacingException.class);
|
||||
intake.confirm(transport.token);
|
||||
intake.confirm(transport.token);
|
||||
assertThat(CrmStore.asUser().by("crm_consent_record", "contactId", contactId)).hasSize(1);
|
||||
assertThat(CrmStore.asUser().get("crm_email_list_member", member.getValueInteger("id")).getValue("confirmedDate")).isNotNull();
|
||||
assertThat(marketing.resolveRecipients("crm-list:" + listId).get(0).address()).isEqualTo("subscriber@example.test");
|
||||
assertThat(intake.subscribe(request).status()).isEqualTo(SubscriptionStatus.ALREADY_SUBSCRIBED);
|
||||
String url = marketing.buildUnsubscribeUrl(contactId, listId);
|
||||
intake.unsubscribe(url.substring(url.lastIndexOf('/') + 1));
|
||||
assertThat(CrmStore.asUser().get("crm_contact", contactId).getValue("unsubscribeDate")).isNull();
|
||||
assertThat(CrmStore.asUser().get("crm_email_list_member", member.getValueInteger("id")).getValueInteger("status")).isEqualTo(2);
|
||||
assertThatThrownBy(() -> intake.confirm(transport.token)).isInstanceOf(QUserFacingException.class);
|
||||
marketing.recordUnsubscribe(contactId, null, "Marketing");
|
||||
assertThat(CrmStore.asUser().get("crm_contact", contactId).getValue("unsubscribeDate")).isNotNull();
|
||||
assertThat(marketing.checkEmailEligibility(contactId).reason()).isEqualTo("unsubscribed");
|
||||
assertThat(marketing.buildUnsubscribeUrl(contactId, null)).contains("/qqq/hooks/crm/unsubscribe/");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Immediate subscription and active options.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void immediateSubscriptionAndActiveOptions() throws Exception
|
||||
{
|
||||
Integer activeId = list("immediate", false);
|
||||
Integer inactiveId = list("inactive", false);
|
||||
CrmStore.asUser().update("crm_email_list", new QRecord().withValue("id", inactiveId).withValue("isActive", false));
|
||||
assertThat(marketing.recipientScopeOptions()).containsExactly(new RecipientScopeOption("crm-list:" + activeId, "immediate"));
|
||||
assertThat(intake.subscribe(signup("immediate")).status()).isEqualTo(SubscriptionStatus.SUBSCRIBED);
|
||||
Integer id = CrmStore.asUser().query("crm_contact", null).get(0).getValueInteger("id");
|
||||
assertThat(marketing.checkEmailEligibility(id).eligible()).isTrue();
|
||||
assertThat(transport.token).isNull();
|
||||
assertThatThrownBy(() -> intake.subscribe(signup("inactive"))).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> intake.subscribe(signup("absent"))).isInstanceOf(QUserFacingException.class);
|
||||
CrmStore.asUser().update("crm_email_list", new QRecord().withValue("id", activeId).withValue("isActive", false));
|
||||
assertThat(marketing.resolveRecipients("crm-list:" + activeId).get(0).eligibility().reason()).isEqualTo("list_inactive");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Tokens reject tampering wrong purpose and expiry.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokensRejectTamperingWrongPurposeAndExpiry() throws Exception
|
||||
{
|
||||
Instant now = Instant.parse("2026-10-14T12:00:00Z");
|
||||
CrmTokens tokens = new CrmTokens(config, Clock.fixed(now, ZoneOffset.UTC));
|
||||
String token = tokens.issue("confirm", 1, 2);
|
||||
assertThat(tokens.verify(token, "confirm").id()).isEqualTo(1);
|
||||
assertThat(tokens.verify(token, "confirm").listId()).isEqualTo(2);
|
||||
assertThat(tokens.verify(token, "confirm").purpose()).isEqualTo("confirm");
|
||||
assertThat(tokens.verify(token, "confirm").expiresAt()).isEqualTo(now.plus(Duration.ofDays(90)));
|
||||
assertThatThrownBy(() -> tokens.verify(token, "unsubscribe")).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> tokens.verify("x" + token.substring(1), "confirm")).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> tokens.verify("bad", "confirm")).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> tokens.verify(null, "confirm")).isInstanceOf(QUserFacingException.class);
|
||||
CrmTokens expired = new CrmTokens(config, Clock.fixed(now.plus(Duration.ofDays(90)), ZoneOffset.UTC));
|
||||
assertThatThrownBy(() -> expired.verify(token, "confirm")).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> tokens.issue("other", 1, null)).isInstanceOf(QUserFacingException.class);
|
||||
config.withPublicBaseUrl(null);
|
||||
assertThatThrownBy(() -> tokens.url("confirm", token)).hasMessageContaining("withPublicBaseUrl");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validates public input before writing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validatesBeforeWriting() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> intake.submitForm(null)).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> intake.subscribe(null)).isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> intake.submitForm(form("invalid", false, "x"))).isInstanceOf(QUserFacingException.class);
|
||||
assertThat(CrmStore.asUser().query("crm_contact", null)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A later Email consent wins, with id as the deterministic equal-time tie breaker.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void latestEmailConsentWinsAndInquiryCannotOverrideIt() throws Exception
|
||||
{
|
||||
Integer id = intake.submitForm(form("latest@example.test", true, "first")).contactId();
|
||||
Instant now = Instant.now().plusSeconds(10);
|
||||
CrmStore.asUser().insert("crm_consent_record", new QRecord().withValue("contactId", id).withValue("consentType", 1)
|
||||
.withValue("status", 2).withValue("consentDate", now));
|
||||
intake.submitForm(form("latest@example.test", false, "reply"));
|
||||
assertThat(marketing.checkEmailEligibility(id).reason()).isEqualTo("email_consent_withdrawn");
|
||||
CrmStore.asUser().insert("crm_consent_record", new QRecord().withValue("contactId", id).withValue("consentType", 1)
|
||||
.withValue("status", 1).withValue("consentDate", now));
|
||||
assertThat(marketing.checkEmailEligibility(id).eligible()).isTrue();
|
||||
assertThat(marketing.resolveRecipients("crm-contact:" + id).get(0).key()).isEqualTo("crm-contact:" + id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A public host session needs no CRM permissions for any intake operation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void intakeRunsWithoutCallerPermissions() throws Exception
|
||||
{
|
||||
Integer listId = list("public", true);
|
||||
list("immediate", false);
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
Integer id = intake.submitForm(form("public@example.test", true, "public")).contactId();
|
||||
assertThat(CrmStore.trusted().by("crm_activity", "contactId", id)).hasSize(1);
|
||||
assertThat(intake.subscribe(signup("public")).status()).isEqualTo(SubscriptionStatus.PENDING_CONFIRMATION);
|
||||
intake.confirm(transport.token);
|
||||
QRecord member = CrmStore.trusted().by("crm_email_list_member", "emailListId", listId).get(0);
|
||||
assertThat(member.getValueInteger("status")).isEqualTo(CrmListMemberStatus.SUBSCRIBED.getId());
|
||||
String token = new CrmTokens(config).issue("unsubscribe", member.getValueInteger("contactId"), listId);
|
||||
intake.unsubscribe(token);
|
||||
assertThat(CrmStore.trusted().get("crm_email_list_member", member.getValueInteger("id")).getValueInteger("status"))
|
||||
.isEqualTo(CrmListMemberStatus.UNSUBSCRIBED.getId());
|
||||
intake.unsubscribeByContact(id, null, "Host request");
|
||||
assertThat(CrmStore.trusted().get("crm_contact", id).getValue("unsubscribeDate")).isNotNull();
|
||||
assertThat(intake.subscribe(signup("immediate")).status()).isEqualTo(SubscriptionStatus.SUBSCRIBED);
|
||||
assertThat(QContext.getQSession().getPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Marketing reads may succeed, but writes still require crm.write.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void marketingCannotWriteWithoutCrmWrite() throws Exception
|
||||
{
|
||||
Integer id = intake.submitForm(form("readonly@example.test", true, "read")).contactId();
|
||||
QContext.getQSession().setPermissions(CrmPermissions.ALL.stream().filter(permission -> !CrmPermissions.WRITE.equals(permission)).collect(java.util.stream.Collectors.toSet()));
|
||||
assertThat(marketing.checkEmailEligibility(id).eligible()).isTrue();
|
||||
assertThatThrownBy(() -> marketing.recordActivity(id, ActivityKind.NOTE, "Denied", "No write permission")).isInstanceOf(QException.class);
|
||||
assertThatThrownBy(() -> marketing.recordUnsubscribe(id, null, "Denied")).isInstanceOf(QException.class);
|
||||
assertThat(CrmStore.trusted().by("crm_activity", "contactId", id)).hasSize(1);
|
||||
assertThat(CrmStore.trusted().get("crm_contact", id).getValue("unsubscribeDate")).isNull();
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
assertThatThrownBy(() -> marketing.recordActivity(id, "Denied", "No permissions")).isInstanceOf(QException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Membership suppression is specific to the requested list, not the contact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsubscribedListDoesNotBlockAnotherListOrDirectSend() throws Exception
|
||||
{
|
||||
Integer listA = list("list-a", false);
|
||||
Integer listB = list("list-b", false);
|
||||
intake.subscribe(signup("list-a"));
|
||||
Integer id = CrmStore.asUser().query("crm_contact", null).get(0).getValueInteger("id");
|
||||
intake.unsubscribeByContact(id, listA, "Only list A");
|
||||
intake.subscribe(signup("list-b"));
|
||||
assertThat(marketing.checkEmailEligibility(id).eligible()).isTrue();
|
||||
assertThat(marketing.resolveRecipients("crm-contact:" + id).get(0).address()).isEqualTo("subscriber@example.test");
|
||||
assertThat(marketing.resolveRecipients("crm-list:" + listB).get(0).address()).isEqualTo("subscriber@example.test");
|
||||
assertThat(marketing.resolveRecipients("crm-list:" + listA).get(0).eligibility().reason()).isEqualTo("list_member_not_subscribed");
|
||||
assertThat(marketing.resolveRecipients("crm-list:" + listA).get(0).address()).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both explicit subscription paths supersede unsubscribe but retain bounce suppression.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void resubscribeClearsGlobalUnsubscribeOnly(boolean doubleOptIn) throws Exception
|
||||
{
|
||||
list("again", doubleOptIn);
|
||||
Integer id = intake.submitForm(form("subscriber@example.test", true, "original")).contactId();
|
||||
intake.unsubscribeByContact(id, null, "Global withdrawal");
|
||||
CrmStore.asUser().update("crm_contact", new QRecord().withValue("id", id).withValue("doNotEmail", true));
|
||||
intake.subscribe(signup("again"));
|
||||
if(doubleOptIn)
|
||||
{
|
||||
assertThat(CrmStore.asUser().get("crm_contact", id).getValue("unsubscribeDate")).isNotNull();
|
||||
intake.confirm(transport.token);
|
||||
}
|
||||
QRecord contact = CrmStore.asUser().get("crm_contact", id);
|
||||
assertThat(contact.getValue("unsubscribeDate")).isNull();
|
||||
assertThat(contact.getValueBoolean("doNotEmail")).isTrue();
|
||||
assertThat(marketing.checkEmailEligibility(id).reason()).isEqualTo("do_not_email");
|
||||
CrmStore.asUser().update("crm_contact", new QRecord().withValue("id", id).withValue("doNotEmail", false));
|
||||
assertThat(marketing.checkEmailEligibility(id).eligible()).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only signed-link operations need token configuration; failure precedes writes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tokenSecretIsRequiredOnlyForSignedOperations() throws Exception
|
||||
{
|
||||
list("immediate", false);
|
||||
list("double", true);
|
||||
config.withTokenSecret(null);
|
||||
Integer id = intake.submitForm(form("no-secret@example.test", false, "plain")).contactId();
|
||||
intake.unsubscribeByContact(id, null, "Host withdrawal");
|
||||
assertThat(CrmStore.asUser().get("crm_contact", id).getValue("unsubscribeDate")).isNotNull();
|
||||
assertThat(intake.subscribe(signup("immediate")).status()).isEqualTo(SubscriptionStatus.SUBSCRIBED);
|
||||
int submissions = CrmStore.asUser().query("crm_form_submission", null).size();
|
||||
assertThatThrownBy(() -> intake.subscribe(signup("double"))).hasMessageContaining("withTokenSecret");
|
||||
assertThat(CrmStore.asUser().query("crm_form_submission", null)).hasSize(submissions);
|
||||
assertThatThrownBy(() -> intake.confirm("token")).hasMessageContaining("withTokenSecret");
|
||||
assertThatThrownBy(() -> intake.unsubscribe("token")).hasMessageContaining("withTokenSecret");
|
||||
assertThatThrownBy(() -> marketing.buildUnsubscribeUrl(id, null)).hasMessageContaining("withTokenSecret");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every public form kind maps to a CRM enum, with typed conversion and consent evidence.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@EnumSource(FormType.class)
|
||||
void formKindsPersistEnumIds(FormType type) throws Exception
|
||||
{
|
||||
CrmFormRequest request = new CrmFormRequest("typed", type, "Ada", "Test", "typed@example.test", null, null, null,
|
||||
false, "Reply", null, null, null, null, null, null, null, null, null);
|
||||
CrmFormResult result = intake.submitForm(request);
|
||||
CrmFormType expected = java.util.Map.of(FormType.CONTACT, CrmFormType.CONTACT, FormType.DEMO, CrmFormType.DEMO_REQUEST,
|
||||
FormType.NEWSLETTER, CrmFormType.NEWSLETTER, FormType.OTHER, CrmFormType.GENERAL).get(type);
|
||||
QRecord submission = CrmStore.asUser().get("crm_form_submission", result.submissionId());
|
||||
assertThat(submission.getValueInteger("formType")).isEqualTo(expected.getId());
|
||||
assertThat(submission.getValueInteger("conversionStatus")).isEqualTo(CrmFormConversionStatus.CONVERTED_TO_CONTACT.getId());
|
||||
QRecord consent = CrmStore.asUser().by("crm_consent_record", "contactId", result.contactId()).get(0);
|
||||
assertThat(consent.getValueInteger("consentType")).isEqualTo(CrmConsentType.REPLY_TO_INQUIRY.getId());
|
||||
assertThat(consent.getValueInteger("status")).isEqualTo(CrmConsentStatus.GRANTED.getId());
|
||||
assertThat(consent.getValueInteger("legalBasis")).isEqualTo(CrmLegalBasis.LEGITIMATE_INTEREST.getId());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unattributed repeat submissions retain every attribution field but refresh the date.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"absent", "blank", "referrer", "utm", "landing"})
|
||||
void lastTouchChangesOnlyWithAttribution(String mode) throws Exception
|
||||
{
|
||||
Integer id = intake.submitForm(form("touch@example.test", false, "original")).contactId();
|
||||
Instant earlier = Instant.parse("2020-01-01T00:00:00Z");
|
||||
CrmStore.asUser().update("crm_contact", new QRecord().withValue("id", id).withValue("lastTouchDate", earlier));
|
||||
QRecord before = CrmStore.asUser().get("crm_contact", id);
|
||||
String empty = "blank".equals(mode) ? " " : null;
|
||||
CrmFormRequest request = new CrmFormRequest("repeat", FormType.CONTACT, "Ada", "Test", "touch@example.test", null, null, null,
|
||||
false, "Reply", empty, empty, empty, empty, "utm".equals(mode) ? "new-term" : empty,
|
||||
"landing".equals(mode) ? "https://example.test/new" : empty, "referrer".equals(mode) ? "https://example.test/referrer" : empty, null, null);
|
||||
intake.submitForm(request);
|
||||
QRecord after = CrmStore.asUser().get("crm_contact", id);
|
||||
for(String suffix : List.of("UtmSource", "UtmMedium", "UtmCampaign", "UtmContent", "UtmTerm", "LandingPage", "Referrer"))
|
||||
{
|
||||
assertThat(after.getValue("firstTouch" + suffix)).isEqualTo(before.getValue("firstTouch" + suffix));
|
||||
if(!Set.of("utm", "landing").contains(mode))
|
||||
{
|
||||
assertThat(after.getValue("lastTouch" + suffix)).isEqualTo(before.getValue("lastTouch" + suffix));
|
||||
}
|
||||
}
|
||||
if("utm".equals(mode))
|
||||
{
|
||||
assertThat(after.getValueString("lastTouchUtmTerm")).isEqualTo("new-term");
|
||||
assertThat(after.getValue("lastTouchUtmCampaign")).isNull();
|
||||
}
|
||||
if("landing".equals(mode))
|
||||
{
|
||||
assertThat(after.getValueString("lastTouchLandingPage")).isEqualTo("https://example.test/new");
|
||||
assertThat(after.getValue("lastTouchUtmCampaign")).isNull();
|
||||
}
|
||||
assertThat(after.getValueInstant("firstTouchDate")).isEqualTo(before.getValueInstant("firstTouchDate"));
|
||||
assertThat(after.getValueInstant("lastTouchDate")).isAfter(earlier);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public intake rotates owners using the shared rules and notifies both audiences.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"system", "anonymous"})
|
||||
void intakeAutoAssignsNewUnownedContactsAndNotifiesOwner(String user) throws Exception
|
||||
{
|
||||
Integer ruleId = CrmStore.asUser().insert("crm_assignment_rule", new QRecord().withValue("name", "Intake round robin")
|
||||
.withValue("entityType", CrmAssignableEntity.CONTACT.getId()).withValue("assignStrategy", CrmAssignStrategy.ROUND_ROBIN.getId())
|
||||
.withValue("isActive", true).withValue("sortOrder", 1)).getValueInteger("id");
|
||||
for(int i = 0; i < 2; i++)
|
||||
{
|
||||
CrmStore.asUser().insert("crm_assignment_rule_member", new QRecord().withValue("assignmentRuleId", ruleId)
|
||||
.withValue("userId", "sales-" + i).withValue("sortOrder", i).withValue("isActive", true));
|
||||
}
|
||||
list("assigned", false);
|
||||
CapturingSink.signals.clear();
|
||||
AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance()).withSink(new QCodeReference(CapturingSink.class));
|
||||
QContext.getQSession().setIdReference(user);
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
CrmFormResult first = intake.submitForm(form("first@example.test", false, "round-robin"));
|
||||
CrmFormResult second = intake.submitForm(form("second@example.test", false, "round-robin"));
|
||||
assertThat(CrmStore.trusted().get("crm_contact", first.contactId()).getValueString("ownerUserId")).isEqualTo("sales-0");
|
||||
assertThat(CrmStore.trusted().get("crm_contact", second.contactId()).getValueString("ownerUserId")).isEqualTo("sales-1");
|
||||
assertThat(CapturingSink.signals).filteredOn(signal -> ("crm.lead.received:" + first.submissionId()).equals(signal.getSourceKey()))
|
||||
.extracting(NotificationSignal::getAudience).containsExactlyInAnyOrder(NotificationAudience.permission("crm.read"), NotificationAudience.user("sales-0"));
|
||||
assertThat(CapturingSink.signals).filteredOn(signal -> ("crm.lead.received:" + second.submissionId()).equals(signal.getSourceKey()))
|
||||
.extracting(NotificationSignal::getAudience).containsExactlyInAnyOrder(NotificationAudience.permission("crm.read"), NotificationAudience.user("sales-1"));
|
||||
intake.submitForm(form("second@example.test", false, "repeat"));
|
||||
assertThat(CrmStore.trusted().get("crm_contact", second.contactId()).getValueString("ownerUserId")).isEqualTo("sales-1");
|
||||
intake.subscribe(signup("assigned"));
|
||||
assertThat(CrmStore.trusted().by("crm_contact", "email", "subscriber@example.test").get(0).getValueString("ownerUserId")).isEqualTo("sales-0");
|
||||
QContext.getQSession().setIdReference("named-owner");
|
||||
Integer owned = intake.submitForm(form("owned@example.test", false, "owned")).contactId();
|
||||
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("named-owner");
|
||||
CrmStore.trusted().update("crm_contact", new QRecord().withValue("id", first.contactId()).withValue("ownerUserId", "system"));
|
||||
intake.submitForm(form("first@example.test", false, "existing-unowned"));
|
||||
assertThat(CrmStore.trusted().get("crm_contact", first.contactId()).getValueString("ownerUserId")).isEqualTo("system");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the real QAlerts dispatch without installing a notification backend.
|
||||
*******************************************************************************/
|
||||
public static class CapturingSink extends LoggingAlertSink
|
||||
{
|
||||
private static final List<NotificationSignal> signals = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
{
|
||||
signals.add(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Synthetic form with complete attribution.
|
||||
*******************************************************************************/
|
||||
private CrmFormRequest form(String email, boolean consent, String campaign)
|
||||
{
|
||||
return (new CrmFormRequest("contact", FormType.CONTACT, "Ada", "Lovelace", email, "555-0100", "Example", "Hello",
|
||||
consent, consent ? "Send me news" : "Reply to my inquiry", "search", "organic", campaign, "banner", "crm",
|
||||
"https://example.test/form", "https://search.example.test", "a".repeat(64), "b".repeat(64)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Synthetic opt-in request.
|
||||
*******************************************************************************/
|
||||
private CrmSubscribeRequest signup(String listKey)
|
||||
{
|
||||
return (new CrmSubscribeRequest(listKey, "subscriber@example.test", null, null, "Send me news", "Website",
|
||||
null, null, "signup", null, null, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active list fixture written through the same actions as host configuration.
|
||||
*******************************************************************************/
|
||||
private Integer list(String key, boolean doubleOptIn) throws Exception
|
||||
{
|
||||
return (CrmStore.asUser().insert("crm_email_list", new QRecord().withValue("listKey", key).withValue("name", key)
|
||||
.withValue("slug", key).withValue("sortOrder", 1).withValue("requireDoubleOptIn", doubleOptIn)
|
||||
.withValue("consentText", "Default wording")).getValueInteger("id"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture confirmation mail without a network provider.
|
||||
*******************************************************************************/
|
||||
private static class FakeTransport extends CrmEmailTransport
|
||||
{
|
||||
private String token;
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the fake with the same instance settings.
|
||||
*******************************************************************************/
|
||||
FakeTransport(CrmQBitConfig config)
|
||||
{
|
||||
super(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrmReadiness readiness()
|
||||
{
|
||||
return (CrmReadiness.CORE_MESSAGING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing)
|
||||
{
|
||||
assertThat(purpose).isEqualTo("confirm");
|
||||
assertThat(marketing).isTrue();
|
||||
assertThat(body).contains("https://crm.example.test/qqq/hooks/crm/confirm/");
|
||||
token = body.substring(body.lastIndexOf('/') + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
import com.kingsrook.qbits.crm.webhook.CrmHookRouteProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP proves mail-scanner GET requests never change subscription preferences.
|
||||
*******************************************************************************/
|
||||
class CrmHookRoutesTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Confirm and unsubscribe require post and reject bad tokens.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void confirmAndUnsubscribeRequirePostAndRejectBadTokens() throws Exception
|
||||
{
|
||||
CrmQBitConfig config = CrmApi.config().withTokenSecret("route-test-secret").withPublicBaseUrl("https://example.test");
|
||||
Integer contactId = CrmStore.asUser().insert("crm_contact", new QRecord().withValue("firstName", "Private").withValue("lastName", "Person")
|
||||
.withValue("email", "private@example.test").withValue("ownerUserId", TEST_USER_ID)).getValueInteger("id");
|
||||
Integer listId = CrmStore.asUser().insert("crm_email_list", new QRecord().withValue("listKey", "news").withValue("slug", "news")
|
||||
.withValue("name", "News").withValue("sortOrder", 1)).getValueInteger("id");
|
||||
Integer memberId = CrmStore.asUser().insert("crm_email_list_member", new QRecord().withValue("contactId", contactId).withValue("emailListId", listId)
|
||||
.withValue("email", "private@example.test").withValue("status", 5).withValue("consentText", "Send me news")).getValueInteger("id");
|
||||
CrmTokens tokens = new CrmTokens(config);
|
||||
String confirm = tokens.issue("confirm", memberId, listId);
|
||||
CrmStore.asUser().update("crm_email_list_member", new QRecord().withValue("id", memberId).withValue("confirmationTokenHash", CrmTokens.hash(confirm)));
|
||||
CrmHookRouteProvider routes = new CrmHookRouteProvider();
|
||||
routes.setQInstance(QContext.getQInstance());
|
||||
CrmHookRouteProvider.register(QContext.getQInstance());
|
||||
CrmHookRouteProvider.register(QContext.getQInstance());
|
||||
assertThat(QJavalinMetaData.ofOrWithNew(QContext.getQInstance()).getAdditionalRouteProviderReferences()).hasSize(1);
|
||||
Javalin server = Javalin.create(routes::acceptJavalinConfig).start(0);
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
String base = "http://localhost:" + server.port() + "/qqq/hooks/crm/";
|
||||
HttpResponse<String> get = request(client, base + "confirm/" + confirm, "GET");
|
||||
assertThat(get.statusCode()).isEqualTo(200);
|
||||
assertThat(get.body()).contains("<button", "method=\"post\"").doesNotContain("private@example.test", confirm);
|
||||
assertThat(get.headers().firstValue("Cache-Control")).contains("no-store");
|
||||
assertThat(CrmStore.asUser().get("crm_email_list_member", memberId).getValueInteger("status")).isEqualTo(5);
|
||||
assertThat(request(client, base + "confirm/" + confirm, "POST").statusCode()).isEqualTo(200);
|
||||
assertThat(CrmStore.asUser().get("crm_email_list_member", memberId).getValueInteger("status")).isEqualTo(1);
|
||||
String unsubscribe = tokens.issue("unsubscribe", contactId, listId);
|
||||
assertThat(request(client, base + "unsubscribe/" + unsubscribe, "GET").statusCode()).isEqualTo(200);
|
||||
assertThat(CrmStore.asUser().get("crm_email_list_member", memberId).getValueInteger("status")).isEqualTo(1);
|
||||
assertThat(request(client, base + "unsubscribe/" + unsubscribe, "POST").statusCode()).isEqualTo(200);
|
||||
assertThat(CrmStore.asUser().get("crm_email_list_member", memberId).getValueInteger("status")).isEqualTo(2);
|
||||
for(String method : java.util.List.of("GET", "POST"))
|
||||
{
|
||||
assertThat(request(client, base + "confirm/invalid", method).statusCode()).isEqualTo(400);
|
||||
assertThat(request(client, base + "unsubscribe/" + confirm, method).statusCode()).isEqualTo(400);
|
||||
String expired = new CrmTokens(config, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC)).issue("unsubscribe", contactId, null);
|
||||
assertThat(request(client, base + "unsubscribe/" + expired, method).statusCode()).isEqualTo(400);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send a browser-style request to the machine route.
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> request(HttpClient client, String url, String method) throws Exception
|
||||
{
|
||||
return (client.send(HttpRequest.newBuilder(URI.create(url)).method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -124,7 +124,7 @@ class FormSubmissionAutoLinkCustomizerTest extends BaseTest
|
||||
{
|
||||
FormSubmission form = new FormSubmission()
|
||||
.withEmail(email)
|
||||
.withFormType(CrmFormType.CONTACT.getPossibleValueId());
|
||||
.withFormKey("test-form").withFormType(CrmFormType.CONTACT.getPossibleValueId());
|
||||
|
||||
if(contactId != null)
|
||||
{
|
||||
|
||||
@@ -119,7 +119,7 @@ class EmailListTest extends BaseTest
|
||||
listInsert.setTableName(EmailList.TABLE_NAME);
|
||||
listInsert.setRecords(List.of(new EmailList()
|
||||
.withName("Newsletter")
|
||||
.withSlug("newsletter")
|
||||
.withListKey("newsletter").withSlug("newsletter")
|
||||
.withIsActive(true)
|
||||
.withIsPublic(true)
|
||||
.withSortOrder(1)
|
||||
|
||||
@@ -48,7 +48,7 @@ class FormSubmissionCoverageTest extends BaseTest
|
||||
|
||||
FormSubmission form = new FormSubmission()
|
||||
.withId(1)
|
||||
.withFormType(1)
|
||||
.withFormKey("test-form").withFormType(1)
|
||||
.withEmail("lead@example.com")
|
||||
.withFirstName("Lead")
|
||||
.withLastName("User")
|
||||
@@ -66,8 +66,6 @@ class FormSubmissionCoverageTest extends BaseTest
|
||||
.withConversionStatus(2)
|
||||
.withConvertedDate(now)
|
||||
.withConvertedByUserId("user-001")
|
||||
.withIpAddress("192.168.1.1")
|
||||
.withUserAgent("Mozilla/5.0")
|
||||
.withMetadata("{\"key\":\"value\"}")
|
||||
.withCreateDate(now)
|
||||
.withModifyDate(now);
|
||||
@@ -91,8 +89,6 @@ class FormSubmissionCoverageTest extends BaseTest
|
||||
assertThat(form.getConversionStatus()).isEqualTo(2);
|
||||
assertThat(form.getConvertedDate()).isEqualTo(now);
|
||||
assertThat(form.getConvertedByUserId()).isEqualTo("user-001");
|
||||
assertThat(form.getIpAddress()).isEqualTo("192.168.1.1");
|
||||
assertThat(form.getUserAgent()).isEqualTo("Mozilla/5.0");
|
||||
assertThat(form.getMetadata()).isEqualTo("{\"key\":\"value\"}");
|
||||
assertThat(form.getCreateDate()).isEqualTo(now);
|
||||
assertThat(form.getModifyDate()).isEqualTo(now);
|
||||
@@ -128,8 +124,6 @@ class FormSubmissionCoverageTest extends BaseTest
|
||||
form.setConversionStatus(3);
|
||||
form.setConvertedDate(now);
|
||||
form.setConvertedByUserId("user-002");
|
||||
form.setIpAddress("10.0.0.1");
|
||||
form.setUserAgent("Chrome/99");
|
||||
form.setMetadata("{\"foo\":\"bar\"}");
|
||||
form.setCreateDate(now);
|
||||
form.setModifyDate(now);
|
||||
@@ -153,8 +147,6 @@ class FormSubmissionCoverageTest extends BaseTest
|
||||
assertThat(form.getConversionStatus()).isEqualTo(3);
|
||||
assertThat(form.getConvertedDate()).isEqualTo(now);
|
||||
assertThat(form.getConvertedByUserId()).isEqualTo("user-002");
|
||||
assertThat(form.getIpAddress()).isEqualTo("10.0.0.1");
|
||||
assertThat(form.getUserAgent()).isEqualTo("Chrome/99");
|
||||
assertThat(form.getMetadata()).isEqualTo("{\"foo\":\"bar\"}");
|
||||
assertThat(form.getCreateDate()).isEqualTo(now);
|
||||
assertThat(form.getModifyDate()).isEqualTo(now);
|
||||
|
||||
@@ -88,8 +88,8 @@ class FormSubmissionTest extends BaseTest
|
||||
///////////////////////////
|
||||
// technical fields //
|
||||
///////////////////////////
|
||||
assertThat(table.getFields()).containsKey("ipAddress");
|
||||
assertThat(table.getFields()).containsKey("userAgent");
|
||||
assertThat(table.getFields()).doesNotContainKey("ipAddress");
|
||||
assertThat(table.getFields()).doesNotContainKey("userAgent");
|
||||
assertThat(table.getFields()).containsKey("metadata");
|
||||
|
||||
assertThat(table.getIcon().getName()).isEqualTo("dynamic_form");
|
||||
@@ -106,7 +106,7 @@ class FormSubmissionTest extends BaseTest
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName(FormSubmission.TABLE_NAME);
|
||||
insertInput.setRecords(List.of(new FormSubmission()
|
||||
.withFormType(CrmFormType.DEMO_REQUEST.getId())
|
||||
.withFormKey("test-form").withFormType(CrmFormType.DEMO_REQUEST.getId())
|
||||
.withEmail("test@example.com")
|
||||
.withFirstName("Jane")
|
||||
.withLastName("Smith")
|
||||
@@ -115,7 +115,6 @@ class FormSubmissionTest extends BaseTest
|
||||
.withUtmMedium("cpc")
|
||||
.withUtmCampaign("spring2026")
|
||||
.withConversionStatus(CrmFormConversionStatus.NEW.getId())
|
||||
.withIpAddress("192.168.1.1")
|
||||
.toQRecord()));
|
||||
InsertOutput insertOutput = new InsertAction().execute(insertInput);
|
||||
assertThat(insertOutput.getRecords()).hasSize(1);
|
||||
@@ -133,7 +132,6 @@ class FormSubmissionTest extends BaseTest
|
||||
assertThat(fetched.getUtmSource()).isEqualTo("google");
|
||||
assertThat(fetched.getUtmMedium()).isEqualTo("cpc");
|
||||
assertThat(fetched.getUtmCampaign()).isEqualTo("spring2026");
|
||||
assertThat(fetched.getIpAddress()).isEqualTo("192.168.1.1");
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -190,7 +190,7 @@ class ConvertFormToContactProcessTest extends BaseTest
|
||||
InsertOutput output = new InsertAction().execute(
|
||||
new InsertInput(FormSubmission.TABLE_NAME).withRecordEntity(
|
||||
new FormSubmission()
|
||||
.withFormType(1)
|
||||
.withFormKey("test-form").withFormType(1)
|
||||
.withEmail(email)
|
||||
.withFirstName(firstName)
|
||||
.withLastName(lastName)
|
||||
|
||||
+1
-1
@@ -225,7 +225,7 @@ class SubscribeToListProcessTest extends BaseTest
|
||||
new InsertInput(EmailList.TABLE_NAME).withRecordEntity(
|
||||
new EmailList()
|
||||
.withName(name)
|
||||
.withSlug(name.toLowerCase())
|
||||
.withListKey(name.toLowerCase()).withSlug(name.toLowerCase())
|
||||
.withIsActive(true)
|
||||
.withIsPublic(true)
|
||||
.withSortOrder(1)));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core fallback exercises the real SendMessageAction and provider dispatcher without sending external mail.
|
||||
*******************************************************************************/
|
||||
public class CrmEmailTransportTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A named core provider receives both transactional and marketing messages.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredCoreProviderReceivesMessages() throws Exception
|
||||
{
|
||||
QMessagingProviderDispatcher.registerMessagingProvider(new RecordingProvider());
|
||||
QContext.getQInstance().addMessagingProvider(new QMessagingProviderMetaData().withName("crm-test").withType("CRM_TEST"));
|
||||
var config = CrmApi.config().withMessagingProviderName("crm-test");
|
||||
CrmEmailTransport transport = new CrmEmailTransport(config);
|
||||
assertThat(transport.readiness()).isEqualTo(CrmReadiness.CORE_MESSAGING);
|
||||
assertThat(CrmApi.readiness()).isEqualTo(CrmReadiness.CORE_MESSAGING);
|
||||
transport.send("one-to-one", "42", 1, "test@example.test", "Hello", "A reply", false);
|
||||
assertThat(RecordingProvider.last.getMessagingProviderName()).isEqualTo("crm-test");
|
||||
assertThat(RecordingProvider.last.getSubject()).isEqualTo("Hello");
|
||||
transport.send("sequence", "43", 1, "test@example.test", "Next step", "A sequence", true);
|
||||
assertThat(RecordingProvider.last.getSubject()).isEqualTo("Next step");
|
||||
RecordingProvider.last = null;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public constructor allows the core dispatcher to instantiate this provider.
|
||||
*******************************************************************************/
|
||||
public static class RecordingProvider implements MessagingProviderInterface
|
||||
{
|
||||
private static SendMessageInput last;
|
||||
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("CRM_TEST");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageOutput sendMessage(SendMessageInput input)
|
||||
{
|
||||
last = input;
|
||||
return (new SendMessageOutput());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user