Compare commits

...
Author SHA1 Message Date
james.maes 0da0edfc6c Merge remote-tracking branch 'origin/develop' into feature/intake-api 2026-10-14 12:46:00 -05:00
james.maesandClaude Opus 5.5 b8dc4041a7 fix(api): trusted intake writes, per-list eligibility, enum ids, re-subscribe, auto-assign new leads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:45:38 -05:00
james.maesandClaude Opus 5.5 2903a1e546 docs: intake and marketing API integration guide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:34:36 -05:00
james.maesandClaude Opus 5.5 bd4b6fd9c4 feat: confirm and unsubscribe hook routes and crm alert types
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:34:35 -05:00
james.maesandClaude Opus 5.5 6acd5ea6e2 feat(api): CrmApi, CrmIntakeApi and CrmMarketingApi public contracts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:34:34 -05:00
james.maesandClaude Opus 5.5 fc2effb070 feat: CrmEmailTransport (qbit-email first, core messaging fallback) and signed CRM tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:34:33 -05:00
james.maesandClaude Opus 5.5 c35b3478ca feat(schema): attribution, consent evidence, list keys and double opt-in fields; no raw IPs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:34:31 -05:00
james.maes 6ecaa4c255 Merge pull request #8 from QRun-IO/feature/platform-4.1
feat!: P1 platform upgrade, crm_ names, permissions and schema
2026-10-14 12:23:17 -05:00
48 changed files with 4307 additions and 424 deletions

No files matched your search

+79
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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()));
}
}
@@ -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");
}
}
@@ -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)
@@ -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());
}
}
}