diff --git a/README.md b/README.md index ab675ff..edb7029 100644 --- a/README.md +++ b/README.md @@ -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::`. 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 scopes = marketing.recipientScopeOptions(); +List 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:` and `crm-contact:`. 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. diff --git a/docs/ddl/postgres.sql b/docs/ddl/postgres.sql index f2c2d22..578fd6a 100644 --- a/docs/ddl/postgres.sql +++ b/docs/ddl/postgres.sql @@ -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); diff --git a/pom.xml b/pom.xml index 5fa2879..8be0196 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,17 @@ + + com.kingsrook.qbits + qbit-email + 0.1.0-RC.3 + true + + + com.kingsrook.qqq + qqq-middleware-javalin + provided + com.kingsrook.qqq qqq-backend-core diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java index 053065c..1768cb6 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java @@ -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 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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index 1ccdec2..ad1cf1d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -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 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); + } } diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java index 6494195..5e47418 100644 --- a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java +++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/api/ActivityKind.java b/src/main/java/com/kingsrook/qbits/crm/api/ActivityKind.java new file mode 100644 index 0000000..fd2bbb8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/ActivityKind.java @@ -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 +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmApi.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmApi.java new file mode 100644 index 0000000..7ebfdac --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmApi.java @@ -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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmFormRequest.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmFormRequest.java new file mode 100644 index 0000000..44710b6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmFormRequest.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmFormResult.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmFormResult.java new file mode 100644 index 0000000..df2175d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmFormResult.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmFormSubmissionRef.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmFormSubmissionRef.java new file mode 100644 index 0000000..7014cc7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmFormSubmissionRef.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java new file mode 100644 index 0000000..69dffb0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java @@ -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 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 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 contacts = contacts(request.email()); + QRecord member = null; + if(!contacts.isEmpty()) + { + List 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 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 contacts) throws QException + { + QRecord contact; + if(contacts.isEmpty()) + { + String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource(); + List 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 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmMarketingApi.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmMarketingApi.java new file mode 100644 index 0000000..1ae5efd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmMarketingApi.java @@ -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 resolveRecipients(String scope) throws QException + { + List 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: or crm-contact:.")); + } + 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 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 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 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmReadiness.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmReadiness.java new file mode 100644 index 0000000..e872d35 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmReadiness.java @@ -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 +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmRecipient.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmRecipient.java new file mode 100644 index 0000000..f0a4010 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmRecipient.java @@ -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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmStarterData.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmStarterData.java new file mode 100644 index 0000000..990e903 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmStarterData.java @@ -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 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; + } + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmStore.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmStore.java new file mode 100644 index 0000000..22b1337 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmStore.java @@ -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 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 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 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmSubscribeRequest.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmSubscribeRequest.java new file mode 100644 index 0000000..88fe200 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmSubscribeRequest.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmSubscribeResult.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmSubscribeResult.java new file mode 100644 index 0000000..a64e391 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmSubscribeResult.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/Eligibility.java b/src/main/java/com/kingsrook/qbits/crm/api/Eligibility.java new file mode 100644 index 0000000..22f67e9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/Eligibility.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/FormType.java b/src/main/java/com/kingsrook/qbits/crm/api/FormType.java new file mode 100644 index 0000000..76c3fbb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/FormType.java @@ -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 +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/RecipientScopeOption.java b/src/main/java/com/kingsrook/qbits/crm/api/RecipientScopeOption.java new file mode 100644 index 0000000..648457e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/RecipientScopeOption.java @@ -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) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/crm/api/SubscriptionStatus.java b/src/main/java/com/kingsrook/qbits/crm/api/SubscriptionStatus.java new file mode 100644 index 0000000..3fbe267 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/api/SubscriptionStatus.java @@ -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 +} diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java b/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java index 878618c..7b1138a 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java b/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java index 8f32546..c5e6c15 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/EmailList.java b/src/main/java/com/kingsrook/qbits/crm/core/model/EmailList.java index 7debc83..25d12c1 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/EmailList.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/EmailList.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java b/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java index b1ebf45..6877a9d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/EmailListMember.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/FormSubmission.java b/src/main/java/com/kingsrook/qbits/crm/core/model/FormSubmission.java index 5e1bb20..9145bc7 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/FormSubmission.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/FormSubmission.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmConsentType.java b/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmConsentType.java index 86e8c35..433e18e 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmConsentType.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmConsentType.java @@ -40,7 +40,8 @@ public enum CrmConsentType implements PossibleValueEnum 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; diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmListMemberStatus.java b/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmListMemberStatus.java index 32e27de..469085c 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmListMemberStatus.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/enums/CrmListMemberStatus.java @@ -40,7 +40,8 @@ public enum CrmListMemberStatus implements PossibleValueEnum 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; diff --git a/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java b/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java new file mode 100644 index 0000000..c76698f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java @@ -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))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/CrmAutoAssign.java b/src/main/java/com/kingsrook/qbits/crm/scoring/CrmAutoAssign.java new file mode 100644 index 0000000..a3be76d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/CrmAutoAssign.java @@ -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 criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class); + for(Map.Entry 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 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 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 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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java index 4109134..dd649bd 100644 --- a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java @@ -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 { - 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 criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class); - for(Map.Entry 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 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 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 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)); - } - } diff --git a/src/main/java/com/kingsrook/qbits/crm/security/CrmTokens.java b/src/main/java/com/kingsrook/qbits/crm/security/CrmTokens.java new file mode 100644 index 0000000..1d82312 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/security/CrmTokens.java @@ -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) + { + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/webhook/CrmHookRouteProvider.java b/src/main/java/com/kingsrook/qbits/crm/webhook/CrmHookRouteProvider.java new file mode 100644 index 0000000..bc311b2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/webhook/CrmHookRouteProvider.java @@ -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("" + + "" + title + + "

" + title + "

" + message + "

" + + (button == null ? "" : "
") + + "
"); + } +} diff --git a/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql b/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql index ecdf177..58cf5d8 100644 --- a/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql @@ -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); diff --git a/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java b/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java index eca3426..bf0c17d 100644 --- a/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java @@ -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); } diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java index 772ba65..aa8bc29 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java @@ -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"); diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java index 294791b..fb8406f 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java @@ -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 diff --git a/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java b/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java new file mode 100644 index 0000000..e0261a1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java @@ -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 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); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/api/CrmHookRoutesTest.java b/src/test/java/com/kingsrook/qbits/crm/api/CrmHookRoutesTest.java new file mode 100644 index 0000000..edd2aa6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/api/CrmHookRoutesTest.java @@ -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 get = request(client, base + "confirm/" + confirm, "GET"); + assertThat(get.statusCode()).isEqualTo(200); + assertThat(get.body()).contains(" 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())); + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java index 852911a..e349e70 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java @@ -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) { diff --git a/src/test/java/com/kingsrook/qbits/crm/core/model/EmailListTest.java b/src/test/java/com/kingsrook/qbits/crm/core/model/EmailListTest.java index c80edc9..71d8e61 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/model/EmailListTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/model/EmailListTest.java @@ -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) diff --git a/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionCoverageTest.java b/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionCoverageTest.java index 9f09b36..657a63b 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionCoverageTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionCoverageTest.java @@ -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); diff --git a/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionTest.java b/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionTest.java index 2142476..8b6f6af 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/model/FormSubmissionTest.java @@ -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"); } } diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java index 22f4a7b..bd8ff76 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java @@ -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) diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java index a816a34..313aa4d 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java @@ -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))); diff --git a/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java b/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java new file mode 100644 index 0000000..7d38c0f --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java @@ -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()); + } + } +}