From 99102d3175d30facee75db537ededb74b157feec Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:54:52 -0500 Subject: [PATCH 1/8] feat(help): qbit-crm help bundle with stand-in loader (qqq#1198) Co-Authored-By: Claude Opus 5.5 --- .../qbits/crm/help/CrmHelpBundleStandIn.java | 135 ++ src/main/resources/help/qbit-crm.yaml | 1568 +++++++++++++++++ .../qbits/crm/CrmPublicationHelpTest.java | 152 ++ 3 files changed, 1855 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/crm/help/CrmHelpBundleStandIn.java create mode 100644 src/main/resources/help/qbit-crm.yaml create mode 100644 src/test/java/com/kingsrook/qbits/crm/CrmPublicationHelpTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/help/CrmHelpBundleStandIn.java b/src/main/java/com/kingsrook/qbits/crm/help/CrmHelpBundleStandIn.java new file mode 100644 index 0000000..d3dc425 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/help/CrmHelpBundleStandIn.java @@ -0,0 +1,135 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm.help; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9) + ** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks + ** process/PVS help slots: action content is applied to frontend steps instead; + ** enum entries remain in the bundle for the central loader. + *******************************************************************************/ +public final class CrmHelpBundleStandIn +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CrmHelpBundleStandIn() + { + } + + + /******************************************************************************* + ** Validate the resource eagerly so malformed shipped help fails production. + *******************************************************************************/ + public static Map entries() throws QException + { + try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml")) + { + JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream); + if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1 + || !"QBIT".equals(bundle.path("layer").asText())) + { + throw (new IOException("Invalid CRM bundle header")); + } + Map entries = new LinkedHashMap<>(); + for(JsonNode entry : bundle.path("entries")) + { + String key = entry.path("key").asText(); + if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120 + || entry.path("content").asText().isBlank() || entries.put(key, entry) != null) + { + throw (new IOException("Invalid or duplicate help entry: " + key)); + } + } + return (entries); + } + catch(IOException | IllegalArgumentException e) + { + throw (new QException("Could not load help/qbit-crm.yaml", e)); + } + } + + + /******************************************************************************* + ** Apply help to the objects produced by this QBit only. + *******************************************************************************/ + public static void apply(MetaDataProducerMultiOutput output) throws QException + { + Map entries = entries(); + for(QTableMetaData table : output.getEach(QTableMetaData.class)) + { + table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help")); + for(var field : table.getFields().values()) + { + String key = "table:" + table.getName() + ";field:" + field.getName(); + if(entries.containsKey(key)) + { + field.withHelpContent(content(entries, key)); + } + } + } + for(QProcessMetaData process : output.getEach(QProcessMetaData.class)) + { + QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help"); + for(var step : process.getStepList()) + { + if(step instanceof QFrontendStepMetaData frontend) + { + frontend.withHelpContent(help.clone()); + } + } + } + for(QAppMetaData app : output.getEach(QAppMetaData.class)) + { + app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help")); + } + } + + + /******************************************************************************* + ** RC.6 has no summary property, so retain the summary in the bundle only. + *******************************************************************************/ + private static QHelpContent content(Map entries, String key) throws QException + { + if(!entries.containsKey(key)) + { + throw (new QException("Missing CRM help entry: " + key)); + } + return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText())); + } +} diff --git a/src/main/resources/help/qbit-crm.yaml b/src/main/resources/help/qbit-crm.yaml new file mode 100644 index 0000000..b5b8dad --- /dev/null +++ b/src/main/resources/help/qbit-crm.yaml @@ -0,0 +1,1568 @@ +bundle: qbit-crm +version: 1 +layer: QBIT +entries: + - key: app:crm;slot:help + summary: "Manage relationships, sales opportunities and consent." + content: | + Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport. + - key: table:crm_job_state;slot:help + summary: "Internal maintenance checkpoints." + content: | + Internal maintenance checkpoints. Health checks use the last successful lead-score run. + - key: table:crm_external_mapping;slot:help + summary: "Links between CRM records and identifiers in external systems." + content: | + Links between CRM records and identifiers in external systems. Keep each system mapping unique. + - key: table:crm_assignment_rule;slot:help + summary: "Rules for selecting the owner of a new contact or deal." + content: | + Rules for selecting the owner of a new contact or deal. + - key: table:crm_assignment_rule_member;slot:help + summary: "Users eligible for a group assignment rule." + content: | + Users eligible for a group assignment rule. Membership determines who can receive assigned work. + - key: table:crm_lead_score_rule;slot:help + summary: "Conditions that add or subtract lead-score points." + content: | + Conditions that add or subtract lead-score points. Review rule order and decay behavior. + - key: table:crm_sales_goal;slot:help + summary: "Revenue targets for a user and period, optionally limited to a pipeline." + content: | + Revenue targets for a user and period, optionally limited to a pipeline. + - key: table:crm_email_message;slot:help + summary: "Delivery information and content for an email associated with a contact or activity." + content: | + Delivery information and content for an email associated with a contact or activity. + - key: table:crm_email_sequence;slot:help + summary: "Ordered outreach steps." + content: | + Ordered outreach steps. Activate only after reviewing the steps, timing and email consent requirements. + - key: table:crm_email_template;slot:help + summary: "Reusable email subject and content." + content: | + Reusable email subject and content. Review merged content before sending to recipients. + - key: table:crm_sequence_enrollment;slot:help + summary: "A contact enrolled in a sequence, with progress, status and next scheduled step." + content: | + A contact enrolled in a sequence, with progress, status and next scheduled step. + - key: table:crm_sequence_step;slot:help + summary: "One timed action in an outreach sequence." + content: | + One timed action in an outreach sequence. Email steps require a usable template and transport. + - key: table:crm_contact_role;slot:help + summary: "Roles a contact can play in a deal or relationship." + content: | + Roles a contact can play in a deal or relationship. + - key: table:crm_deal;slot:help + summary: "Sales opportunities tracked through a pipeline." + content: | + Sales opportunities tracked through a pipeline. Select a pipeline before choosing its stage. + - key: table:crm_deal;field:id + summary: "System-assigned record identifier." + content: | + System-assigned record identifier. Use this value when linking records or investigating an import. + - key: table:crm_deal;field:name + summary: "The display name shown in lists and record links." + content: | + The display name shown in lists and record links. Choose a name that is easy to recognize. + - key: table:crm_deal;field:pipelineId + summary: "The sales pipeline for this record." + content: | + The sales pipeline for this record. Its stages determine the available stage choices. + - key: table:crm_deal;field:pipelineStageId + summary: "The current stage within the chosen pipeline." + content: | + The current stage within the chosen pipeline. A stage from another pipeline is rejected. + - key: table:crm_deal;field:amount + summary: "The deal amount in the selected currency." + content: | + The deal amount in the selected currency. Product changes can recalculate this amount. + - key: table:crm_deal;field:currencyCode + summary: "Currency of the deal amount." + content: | + Currency of the deal amount. Choose a configured currency code. + - key: table:crm_deal;field:amountInBaseCurrency + summary: "The deal amount converted to the configured base currency for reporting." + content: | + The deal amount converted to the configured base currency for reporting. + - key: table:crm_deal;field:probabilityOverridePct + summary: "Optional closing probability percentage that overrides the stage probability." + content: | + Optional closing probability percentage that overrides the stage probability. + - key: table:crm_deal;field:weightedAmount + summary: "The deal amount weighted by its probability of closing." + content: | + The deal amount weighted by its probability of closing. + - key: table:crm_deal;field:companyId + summary: "The linked company." + content: | + The linked company. Select an existing company so contacts and deals share the same organization. + - key: table:crm_deal;field:primaryContactId + summary: "The main contact for this deal." + content: | + The main contact for this deal. Choose the company first to narrow the choices. + - key: table:crm_deal;field:ownerUserId + summary: "The user responsible for following up on this record." + content: | + The user responsible for following up on this record. + - key: table:crm_deal;field:leadSourceId + summary: "The source that brought this contact or deal to your organization." + content: | + The source that brought this contact or deal to your organization. + - key: table:crm_deal;field:expectedCloseDate + summary: "The date you expect the opportunity to close." + content: | + The date you expect the opportunity to close. Keep it current for forecasts. + - key: table:crm_deal;field:actualCloseDate + summary: "When the deal was won or lost." + content: | + When the deal was won or lost. Reopening clears the closed state. + - key: table:crm_deal;field:winLossReasonId + summary: "Why the deal was won or lost." + content: | + Why the deal was won or lost. Select the reason that best explains the outcome. + - key: table:crm_deal;field:stageEnteredDate + summary: "When the deal entered its current stage, used to identify deals that have stalled." + content: | + When the deal entered its current stage, used to identify deals that have stalled. + - key: table:crm_deal;field:description + summary: "Additional context for this record." + content: | + Additional context for this record. Include only information needed for the relationship. + - key: table:crm_deal;field:priority + summary: "Relative urgency used to organize work." + content: | + Relative urgency used to organize work. + - key: table:crm_deal;field:lastActivityDate + summary: "The latest logged interaction date maintained by the CRM." + content: | + The latest logged interaction date maintained by the CRM. + - key: table:crm_deal;field:createDate + summary: "When this record was created." + content: | + When this record was created. The system maintains this timestamp. + - key: table:crm_deal;field:modifyDate + summary: "When this record was last changed." + content: | + When this record was last changed. The system maintains this timestamp. + - key: table:crm_deal;field:tenantKey + summary: "The tenant that owns this record." + content: | + Available only when the host enables CRM tenancy. The session must hold the matching tenant key; changing this value changes who can access the record. + - key: table:crm_deal_contact;slot:help + summary: "Contacts involved in a deal, with their relationship or role." + content: | + Contacts involved in a deal, with their relationship or role. + - key: table:crm_deal_product;slot:help + summary: "Product quantities and prices on a deal." + content: | + Product quantities and prices on a deal. Changes recalculate the deal total. + - key: table:crm_deal_stage_history;slot:help + summary: "The history of a deal moving between pipeline stages." + content: | + The history of a deal moving between pipeline stages. + - key: table:crm_pipeline;slot:help + summary: "Sales pipelines that group stages." + content: | + Sales pipelines that group stages. Give each pipeline a clear purpose and ordered stages. + - key: table:crm_pipeline_stage;slot:help + summary: "Ordered stages within a pipeline, with probability, required fields and won or lost outcomes." + content: | + Ordered stages within a pipeline, with probability, required fields and won or lost outcomes. + - key: table:crm_product;slot:help + summary: "Products and services available as deal line items." + content: | + Products and services available as deal line items. Check price and currency before adding to a deal. + - key: table:crm_win_loss_reason;slot:help + summary: "Reasons for winning or losing a deal." + content: | + Reasons for winning or losing a deal. Use these to improve pipeline reporting. + - key: table:crm_company;slot:help + summary: "Organizations linked to contacts and deals." + content: | + Organizations linked to contacts and deals. Use domain to distinguish similarly named companies. + - key: table:crm_company;field:id + summary: "System-assigned record identifier." + content: | + System-assigned record identifier. Use this value when linking records or investigating an import. + - key: table:crm_company;field:name + summary: "The display name shown in lists and record links." + content: | + The display name shown in lists and record links. Choose a name that is easy to recognize. + - key: table:crm_company;field:domain + summary: "The company's internet domain, for example example.com." + content: | + The company's internet domain, for example example.com. Use it to distinguish organizations. + - key: table:crm_company;field:companyType + summary: "The company's relationship to your organization, such as customer or prospect." + content: | + The company's relationship to your organization, such as customer or prospect. + - key: table:crm_company;field:industryId + summary: "The industry that best describes the company's business." + content: | + The industry that best describes the company's business. + - key: table:crm_company;field:employeeCount + summary: "The approximate number of people employed by the company." + content: | + The approximate number of people employed by the company. + - key: table:crm_company;field:annualRevenue + summary: "The company's approximate annual revenue in its revenue currency." + content: | + The company's approximate annual revenue in its revenue currency. + - key: table:crm_company;field:annualRevenueCurrencyCode + summary: "Currency used for the annual revenue estimate." + content: | + Currency used for the annual revenue estimate. + - key: table:crm_company;field:phone + summary: "A telephone number including country code where needed." + content: | + A telephone number including country code where needed. Respect the contact's calling preferences. + - key: table:crm_company;field:website + summary: "The public website URL for this person or organization." + content: | + The public website URL for this person or organization. + - key: table:crm_company;field:addressLine1 + summary: "The first line of the postal address, such as street and building number." + content: | + The first line of the postal address, such as street and building number. + - key: table:crm_company;field:addressLine2 + summary: "Additional address details, such as apartment or suite." + content: | + Additional address details, such as apartment or suite. + - key: table:crm_company;field:city + summary: "City or locality in the postal address." + content: | + City or locality in the postal address. + - key: table:crm_company;field:state + summary: "State, province or region in the postal address." + content: | + State, province or region in the postal address. + - key: table:crm_company;field:postalCode + summary: "Postal or ZIP code in the address." + content: | + Postal or ZIP code in the address. + - key: table:crm_company;field:country + summary: "Country in the postal address." + content: | + Country in the postal address. + - key: table:crm_company;field:ownerUserId + summary: "The user responsible for following up on this record." + content: | + The user responsible for following up on this record. + - key: table:crm_company;field:parentCompanyId + summary: "The parent organization, if this company is part of a larger group." + content: | + The parent organization, if this company is part of a larger group. + - key: table:crm_company;field:description + summary: "Additional context for this record." + content: | + Additional context for this record. Include only information needed for the relationship. + - key: table:crm_company;field:lastActivityDate + summary: "The latest logged interaction date maintained by the CRM." + content: | + The latest logged interaction date maintained by the CRM. + - key: table:crm_company;field:lastContactedDate + summary: "Last contacted date for this company." + content: | + Last contacted date for this company. Review this value when updating the record. + - key: table:crm_company;field:createDate + summary: "When this record was created." + content: | + When this record was created. The system maintains this timestamp. + - key: table:crm_company;field:modifyDate + summary: "When this record was last changed." + content: | + When this record was last changed. The system maintains this timestamp. + - key: table:crm_company;field:tenantKey + summary: "The tenant that owns this record." + content: | + Available only when the host enables CRM tenancy. The session must hold the matching tenant key; changing this value changes who can access the record. + - key: table:crm_consent_record;slot:help + summary: "Immutable evidence of a consent decision." + content: | + Immutable evidence of a consent decision. Record withdrawals as new records; erasure redacts evidence text. + - key: table:crm_contact;slot:help + summary: "People and their relationship with your organization." + content: | + People and their relationship with your organization. Check consent before sending marketing email. + - key: table:crm_contact;field:firstTouchUtmSource + summary: "First-touch campaign attribution: first touch utm source." + content: | + First-touch campaign attribution: first touch utm source. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:firstTouchUtmMedium + summary: "First-touch campaign attribution: first touch utm medium." + content: | + First-touch campaign attribution: first touch utm medium. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:firstTouchUtmCampaign + summary: "First-touch campaign attribution: first touch utm campaign." + content: | + First-touch campaign attribution: first touch utm campaign. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:firstTouchUtmContent + summary: "First-touch campaign attribution: first touch utm content." + content: | + First-touch campaign attribution: first touch utm content. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:firstTouchUtmTerm + summary: "First-touch campaign attribution: first touch utm term." + content: | + First-touch campaign attribution: first touch utm term. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:firstTouchLandingPage + summary: "First touch landing page." + content: | + First touch landing page. Captured from the visitor journey for attribution reporting. + - key: table:crm_contact;field:firstTouchReferrer + summary: "First touch referrer." + content: | + First touch referrer. Captured from the visitor journey for attribution reporting. + - key: table:crm_contact;field:firstTouchDate + summary: "First touch date." + content: | + First touch date. Captured from the visitor journey for attribution reporting. + - key: table:crm_contact;field:lastTouchUtmSource + summary: "Latest-touch campaign attribution: last touch utm source." + content: | + Latest-touch campaign attribution: last touch utm source. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:lastTouchUtmMedium + summary: "Latest-touch campaign attribution: last touch utm medium." + content: | + Latest-touch campaign attribution: last touch utm medium. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:lastTouchUtmCampaign + summary: "Latest-touch campaign attribution: last touch utm campaign." + content: | + Latest-touch campaign attribution: last touch utm campaign. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:lastTouchUtmContent + summary: "Latest-touch campaign attribution: last touch utm content." + content: | + Latest-touch campaign attribution: last touch utm content. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:lastTouchUtmTerm + summary: "Latest-touch campaign attribution: last touch utm term." + content: | + Latest-touch campaign attribution: last touch utm term. Supplied by the host from the visitor journey. + - key: table:crm_contact;field:lastTouchLandingPage + summary: "Last touch landing page." + content: | + Last touch landing page. Captured from the visitor journey for attribution reporting. + - key: table:crm_contact;field:lastTouchReferrer + summary: "Last touch referrer." + content: | + Last touch referrer. Captured from the visitor journey for attribution reporting. + - key: table:crm_contact;field:lastTouchDate + summary: "Last touch date." + content: | + Last touch date. Captured from the visitor journey for attribution reporting. + - key: table:crm_contact;field:id + summary: "System-assigned record identifier." + content: | + System-assigned record identifier. Use this value when linking records or investigating an import. + - key: table:crm_contact;field:firstName + summary: "The person's given name." + content: | + The person's given name. This is personal data and is removed by Erase Personal Data. + - key: table:crm_contact;field:lastName + summary: "The person's family name." + content: | + The person's family name. This is personal data and is removed by Erase Personal Data. + - key: table:crm_contact;field:email + summary: "Email address used to identify or contact the person." + content: | + Email address used to identify or contact the person. Marketing still requires valid, unsuppressed consent. + - key: table:crm_contact;field:secondaryEmail + summary: "An alternate email address for the contact." + content: | + An alternate email address for the contact. It is included in personal-data erasure. + - key: table:crm_contact;field:phone + summary: "A telephone number including country code where needed." + content: | + A telephone number including country code where needed. Respect the contact's calling preferences. + - key: table:crm_contact;field:mobilePhone + summary: "The contact's mobile telephone number." + content: | + The contact's mobile telephone number. Respect the contact's calling preferences. + - key: table:crm_contact;field:jobTitle + summary: "The contact's job title at their organization." + content: | + The contact's job title at their organization. + - key: table:crm_contact;field:department + summary: "The department or team where the contact works." + content: | + The department or team where the contact works. + - key: table:crm_contact;field:companyId + summary: "The linked company." + content: | + The linked company. Select an existing company so contacts and deals share the same organization. + - key: table:crm_contact;field:lifecycleStageId + summary: "The contact's current relationship stage, such as lead or customer." + content: | + The contact's current relationship stage, such as lead or customer. + - key: table:crm_contact;field:leadSourceId + summary: "The source that brought this contact or deal to your organization." + content: | + The source that brought this contact or deal to your organization. + - key: table:crm_contact;field:ownerUserId + summary: "The user responsible for following up on this record." + content: | + The user responsible for following up on this record. + - key: table:crm_contact;field:addressLine1 + summary: "The first line of the postal address, such as street and building number." + content: | + The first line of the postal address, such as street and building number. + - key: table:crm_contact;field:addressLine2 + summary: "Additional address details, such as apartment or suite." + content: | + Additional address details, such as apartment or suite. + - key: table:crm_contact;field:city + summary: "City or locality in the postal address." + content: | + City or locality in the postal address. + - key: table:crm_contact;field:state + summary: "State, province or region in the postal address." + content: | + State, province or region in the postal address. + - key: table:crm_contact;field:postalCode + summary: "Postal or ZIP code in the address." + content: | + Postal or ZIP code in the address. + - key: table:crm_contact;field:country + summary: "Country in the postal address." + content: | + Country in the postal address. + - key: table:crm_contact;field:timezone + summary: "The contact's time-zone identifier, such as America/Chicago, used when scheduling sequence steps." + content: | + The contact's time-zone identifier, such as America/Chicago, used when scheduling sequence steps. + - key: table:crm_contact;field:preferredLanguage + summary: "The contact's preferred language for communication." + content: | + The contact's preferred language for communication. + - key: table:crm_contact;field:website + summary: "The public website URL for this person or organization." + content: | + The public website URL for this person or organization. + - key: table:crm_contact;field:linkedinUrl + summary: "The contact's LinkedIn profile URL." + content: | + The contact's LinkedIn profile URL. This is classified as personal data. + - key: table:crm_contact;field:description + summary: "Additional context for this record." + content: | + Additional context for this record. Include only information needed for the relationship. + - key: table:crm_contact;field:leadScore + summary: "The latest calculated score from the enabled lead-score rules." + content: | + The latest calculated score from the enabled lead-score rules. + - key: table:crm_contact;field:doNotEmail + summary: "Suppress email to this contact even when a marketing consent record exists." + content: | + Suppress email to this contact even when a marketing consent record exists. + - key: table:crm_contact;field:doNotCall + summary: "Indicates that this contact should not receive telephone calls." + content: | + Indicates that this contact should not receive telephone calls. + - key: table:crm_contact;field:unsubscribeDate + summary: "When the contact unsubscribed." + content: | + When the contact unsubscribed. This suppresses marketing email. + - key: table:crm_contact;field:lastActivityDate + summary: "The latest logged interaction date maintained by the CRM." + content: | + The latest logged interaction date maintained by the CRM. + - key: table:crm_contact;field:lastContactedDate + summary: "Last contacted date for this contact." + content: | + Last contacted date for this contact. Review this value when updating the record. + - key: table:crm_contact;field:createDate + summary: "When this record was created." + content: | + When this record was created. The system maintains this timestamp. + - key: table:crm_contact;field:modifyDate + summary: "When this record was last changed." + content: | + When this record was last changed. The system maintains this timestamp. + - key: table:crm_contact;field:tenantKey + summary: "The tenant that owns this record." + content: | + Available only when the host enables CRM tenancy. The session must hold the matching tenant key; changing this value changes who can access the record. + - key: table:crm_currency;slot:help + summary: "Currency codes and exchange rates used to report deal values in a base currency." + content: | + Currency codes and exchange rates used to report deal values in a base currency. + - key: table:crm_email_list;slot:help + summary: "Mailing lists and opt-in settings." + content: | + Mailing lists and opt-in settings. List membership alone does not grant permission to send marketing email. + - key: table:crm_email_list;field:listKey + summary: "A stable integration key used by hosts to subscribe people to this list." + content: | + A stable integration key used by hosts to subscribe people to this list. + - key: table:crm_email_list;field:requireDoubleOptIn + summary: "Require double opt in for this email list." + content: | + Require double opt in for this email list. Review this value when updating the record. + - key: table:crm_email_list;field:consentText + summary: "The exact wording shown when consent was requested." + content: | + The exact wording shown when consent was requested. Treat this as personal evidence. + - key: table:crm_email_list;field:id + summary: "System-assigned record identifier." + content: | + System-assigned record identifier. Use this value when linking records or investigating an import. + - key: table:crm_email_list;field:name + summary: "The display name shown in lists and record links." + content: | + The display name shown in lists and record links. Choose a name that is easy to recognize. + - key: table:crm_email_list;field:slug + summary: "A short machine-friendly name for this list." + content: | + A short machine-friendly name for this list. + - key: table:crm_email_list;field:description + summary: "Additional context for this record." + content: | + Additional context for this record. Include only information needed for the relationship. + - key: table:crm_email_list;field:isActive + summary: "Whether this record is available for new work." + content: | + Whether this record is available for new work. + - key: table:crm_email_list;field:isPublic + summary: "Is public for this email list." + content: | + Is public for this email list. Review this value when updating the record. + - key: table:crm_email_list;field:sortOrder + summary: "Sort order for this email list." + content: | + Sort order for this email list. Review this value when updating the record. + - key: table:crm_email_list;field:memberCount + summary: "Member count for this email list." + content: | + Member count for this email list. Review this value when updating the record. + - key: table:crm_email_list;field:createDate + summary: "When this record was created." + content: | + When this record was created. The system maintains this timestamp. + - key: table:crm_email_list;field:modifyDate + summary: "When this record was last changed." + content: | + When this record was last changed. The system maintains this timestamp. + - key: table:crm_email_list;field:tenantKey + summary: "The tenant that owns this record." + content: | + Available only when the host enables CRM tenancy. The session must hold the matching tenant key; changing this value changes who can access the record. + - key: table:crm_email_list_member;slot:help + summary: "A contact or email address in a list, including opt-in state and confirmation evidence." + content: | + A contact or email address in a list, including opt-in state and confirmation evidence. + - key: table:crm_form_submission;slot:help + summary: "Web leads captured from forms, including consent evidence and attribution." + content: | + Web leads captured from forms, including consent evidence and attribution. Convert a lead to link a contact. + - key: table:crm_form_submission;field:formKey + summary: "The stable host-defined identifier for the form that captured this submission." + content: | + The stable host-defined identifier for the form that captured this submission. + - key: table:crm_form_submission;field:marketingConsent + summary: "Marketing consent for this form submission." + content: | + Marketing consent for this form submission. Review this value when updating the record. + - key: table:crm_form_submission;field:consentText + summary: "The exact wording shown when consent was requested." + content: | + The exact wording shown when consent was requested. Treat this as personal evidence. + - key: table:crm_form_submission;field:landingPage + summary: "The landing page associated with this submission." + content: | + The landing page associated with this submission. + - key: table:crm_form_submission;field:visitorKey + summary: "A hashed visitor identifier, stored only when the host has the required visitor-identity consent." + content: | + A hashed visitor identifier, stored only when the host has the required visitor-identity consent. + - key: table:crm_form_submission;field:clientHash + summary: "A salted client identifier supplied by the host." + content: | + A salted client identifier supplied by the host. Never put a raw IP address here. + - key: table:crm_form_submission;field:id + summary: "System-assigned record identifier." + content: | + System-assigned record identifier. Use this value when linking records or investigating an import. + - key: table:crm_form_submission;field:formType + summary: "The purpose of the submitted form." + content: | + The purpose of the submitted form. A contact form does not automatically grant marketing consent. + - key: table:crm_form_submission;field:email + summary: "Email address used to identify or contact the person." + content: | + Email address used to identify or contact the person. Marketing still requires valid, unsuppressed consent. + - key: table:crm_form_submission;field:firstName + summary: "The person's given name." + content: | + The person's given name. This is personal data and is removed by Erase Personal Data. + - key: table:crm_form_submission;field:lastName + summary: "The person's family name." + content: | + The person's family name. This is personal data and is removed by Erase Personal Data. + - key: table:crm_form_submission;field:company + summary: "The company name as submitted on the form; review it before linking to an existing organization." + content: | + The company name as submitted on the form; review it before linking to an existing organization. + - key: table:crm_form_submission;field:phone + summary: "A telephone number including country code where needed." + content: | + A telephone number including country code where needed. Respect the contact's calling preferences. + - key: table:crm_form_submission;field:message + summary: "The message submitted by the person." + content: | + The message submitted by the person. Avoid copying sensitive information into unrelated records. + - key: table:crm_form_submission;field:sourceUrl + summary: "The page URL where the form was submitted." + content: | + The page URL where the form was submitted. + - key: table:crm_form_submission;field:utmSource + summary: "Captured campaign attribution: utm source." + content: | + Captured campaign attribution: utm source. Supplied by the host from the visitor journey. + - key: table:crm_form_submission;field:utmMedium + summary: "Captured campaign attribution: utm medium." + content: | + Captured campaign attribution: utm medium. Supplied by the host from the visitor journey. + - key: table:crm_form_submission;field:utmCampaign + summary: "Captured campaign attribution: utm campaign." + content: | + Captured campaign attribution: utm campaign. Supplied by the host from the visitor journey. + - key: table:crm_form_submission;field:utmTerm + summary: "Captured campaign attribution: utm term." + content: | + Captured campaign attribution: utm term. Supplied by the host from the visitor journey. + - key: table:crm_form_submission;field:utmContent + summary: "Captured campaign attribution: utm content." + content: | + Captured campaign attribution: utm content. Supplied by the host from the visitor journey. + - key: table:crm_form_submission;field:referrer + summary: "The referring page captured by the host." + content: | + The referring page captured by the host. + - key: table:crm_form_submission;field:contactId + summary: "The linked contact." + content: | + The linked contact. This connects the record with that person's history and consent. + - key: table:crm_form_submission;field:conversionStatus + summary: "Whether this web lead has been converted or linked to a contact." + content: | + Whether this web lead has been converted or linked to a contact. + - key: table:crm_form_submission;field:convertedDate + summary: "When the submission was converted to a contact." + content: | + When the submission was converted to a contact. + - key: table:crm_form_submission;field:convertedByUserId + summary: "The user who converted the submission to a contact." + content: | + The user who converted the submission to a contact. + - key: table:crm_form_submission;field:metadata + summary: "Additional host-supplied form details." + content: | + Additional host-supplied form details. Do not store credentials or raw IP addresses. + - key: table:crm_form_submission;field:createDate + summary: "When this record was created." + content: | + When this record was created. The system maintains this timestamp. + - key: table:crm_form_submission;field:modifyDate + summary: "When this record was last changed." + content: | + When this record was last changed. The system maintains this timestamp. + - key: table:crm_form_submission;field:tenantKey + summary: "The tenant that owns this record." + content: | + Available only when the host enables CRM tenancy. The session must hold the matching tenant key; changing this value changes who can access the record. + - key: table:crm_industry;slot:help + summary: "Industry categories for companies." + content: | + Industry categories for companies. Choose one that describes the primary business. + - key: table:crm_lead_source;slot:help + summary: "Where a contact or deal originated." + content: | + Where a contact or deal originated. Use consistent categories for attribution reports. + - key: table:crm_lifecycle_stage;slot:help + summary: "Lifecycle categories for contacts, such as lead and customer." + content: | + Lifecycle categories for contacts, such as lead and customer. + - key: table:crm_activity;slot:help + summary: "Past interactions with contacts, companies and deals." + content: | + Past interactions with contacts, companies and deals. Use collaboration tasks for future work. + - key: table:crm_activity;field:id + summary: "System-assigned record identifier." + content: | + System-assigned record identifier. Use this value when linking records or investigating an import. + - key: table:crm_activity;field:activityTypeId + summary: "The kind of interaction." + content: | + The kind of interaction. Choose this before selecting an outcome. + - key: table:crm_activity;field:subject + summary: "A short description of the interaction." + content: | + A short description of the interaction. Avoid unnecessary personal information. + - key: table:crm_activity;field:description + summary: "Additional context for this record." + content: | + Additional context for this record. Include only information needed for the relationship. + - key: table:crm_activity;field:contactId + summary: "The linked contact." + content: | + The linked contact. This connects the record with that person's history and consent. + - key: table:crm_activity;field:companyId + summary: "The linked company." + content: | + The linked company. Select an existing company so contacts and deals share the same organization. + - key: table:crm_activity;field:dealId + summary: "The related sales opportunity." + content: | + The related sales opportunity. Link the deal to include this interaction in its history. + - key: table:crm_activity;field:ownerUserId + summary: "The user responsible for following up on this record." + content: | + The user responsible for following up on this record. + - key: table:crm_activity;field:assignedToUserId + summary: "The user assigned to handle this activity." + content: | + The user assigned to handle this activity. + - key: table:crm_activity;field:startDate + summary: "When the interaction started." + content: | + When the interaction started. + - key: table:crm_activity;field:endDate + summary: "When the interaction ended." + content: | + When the interaction ended. + - key: table:crm_activity;field:dueDate + summary: "The due date recorded on the activity." + content: | + The due date recorded on the activity. Use collaboration tasks to manage future to-dos. + - key: table:crm_activity;field:completedDate + summary: "When this activity was completed." + content: | + When this activity was completed. + - key: table:crm_activity;field:isCompleted + summary: "Whether this activity is complete." + content: | + Whether this activity is complete. + - key: table:crm_activity;field:priority + summary: "Relative urgency used to organize work." + content: | + Relative urgency used to organize work. + - key: table:crm_activity;field:durationMinutes + summary: "Duration of the interaction in minutes." + content: | + Duration of the interaction in minutes. + - key: table:crm_activity;field:outcomeId + summary: "The result of the interaction." + content: | + The result of the interaction. It must belong to the selected activity type. + - key: table:crm_activity;field:location + summary: "Where the interaction took place." + content: | + Where the interaction took place. + - key: table:crm_activity;field:conferenceLink + summary: "Meeting link associated with the interaction." + content: | + Meeting link associated with the interaction. + - key: table:crm_activity;field:direction + summary: "Whether the interaction was inbound or outbound." + content: | + Whether the interaction was inbound or outbound. + - key: table:crm_activity;field:isReminderSet + summary: "Legacy activity reminder state." + content: | + Legacy activity reminder state. Use collaboration tasks for current reminder workflows. + - key: table:crm_activity;field:reminderDate + summary: "Legacy activity reminder time." + content: | + Legacy activity reminder time. Use collaboration tasks for current reminder workflows. + - key: table:crm_activity;field:externalCalendarId + summary: "Identifier of the related event in the host calendar system." + content: | + Identifier of the related event in the host calendar system. + - key: table:crm_activity;field:createDate + summary: "When this record was created." + content: | + When this record was created. The system maintains this timestamp. + - key: table:crm_activity;field:modifyDate + summary: "When this record was last changed." + content: | + When this record was last changed. The system maintains this timestamp. + - key: table:crm_activity;field:tenantKey + summary: "The tenant that owns this record." + content: | + Available only when the host enables CRM tenancy. The session must hold the matching tenant key; changing this value changes who can access the record. + - key: table:crm_activity_outcome;slot:help + summary: "The result of an activity." + content: | + The result of an activity. Only outcomes belonging to the selected activity type are offered. + - key: table:crm_activity_participant;slot:help + summary: "People participating in a recorded activity and their roles." + content: | + People participating in a recorded activity and their roles. + - key: table:crm_activity_type;slot:help + summary: "Types of interaction such as calls, meetings and notes." + content: | + Types of interaction such as calls, meetings and notes. Outcomes are scoped to a type. + - key: possibleValueSource:CrmActivityPriority;slot:help + summary: "Available activity priority values." + content: | + Choose the value that describes the record. Options: Low, Medium, High. + - key: possibleValueSource:CrmAssignableEntity;slot:help + summary: "Available assignable entity values." + content: | + Choose the value that describes the record. Options: Contact, Deal. + - key: possibleValueSource:CrmAssignStrategy;slot:help + summary: "Available assign strategy values." + content: | + Choose the value that describes the record. Options: Specific User, Round Robin, Least Active. + - key: possibleValueSource:CrmBounceType;slot:help + summary: "Available bounce type values." + content: | + Choose the value that describes the record. Options: Hard, Soft. + - key: possibleValueSource:CrmCompanyType;slot:help + summary: "Available company type values." + content: | + Choose the value that describes the record. Options: Prospect, Customer, Partner, Competitor, Other. + - key: possibleValueSource:CrmConsentStatus;slot:help + summary: "Available consent status values." + content: | + Choose the value that describes the record. Options: Granted, Withdrawn. + - key: possibleValueSource:CrmConsentType;slot:help + summary: "Available consent type values." + content: | + Choose the value that describes the record. Options: Email, Call, SMS, Data Processing, Reply to inquiry. + - key: possibleValueSource:CrmDealPriority;slot:help + summary: "Available deal priority values." + content: | + Choose the value that describes the record. Options: Low, Medium, High, Critical. + - key: possibleValueSource:CrmDirection;slot:help + summary: "Available direction values." + content: | + Choose the value that describes the record. Options: Inbound, Outbound. + - key: possibleValueSource:CrmEmailStatus;slot:help + summary: "Available email status values." + content: | + Choose the value that describes the record. Options: Draft, Sent, Delivered, Failed, Queued, Bounced, Complained, Opened, Clicked, Suppressed. + - key: possibleValueSource:CrmEnrollmentStatus;slot:help + summary: "Available enrollment status values." + content: | + Choose the value that describes the record. Options: Active, Paused, Completed, Bounced, Replied, Unenrolled, Failed. + - key: possibleValueSource:CrmEntityType;slot:help + summary: "Available entity type values." + content: | + Choose the value that describes the record. Options: Contact, Company, Deal, Activity. + - key: possibleValueSource:CrmFormConversionStatus;slot:help + summary: "Available form conversion status values." + content: | + Choose the value that describes the record. Options: New, Converted to Contact, Spam, Ignored. + - key: possibleValueSource:CrmFormType;slot:help + summary: "Available form type values." + content: | + Choose the value that describes the record. Options: Contact, Demo Request, Newsletter, Notify Me, General. + - key: possibleValueSource:CrmGoalPeriod;slot:help + summary: "Available goal period values." + content: | + Choose the value that describes the record. Options: Monthly, Quarterly, Annual. + - key: possibleValueSource:CrmLegalBasis;slot:help + summary: "Available legal basis values." + content: | + Choose the value that describes the record. Options: Consent, Legitimate Interest, Contract, Legal Obligation. + - key: possibleValueSource:CrmListMemberStatus;slot:help + summary: "Available list member status values." + content: | + Choose the value that describes the record. Options: Subscribed, Unsubscribed, Bounced, Cleaned, Pending confirmation. + - key: possibleValueSource:CrmParticipantRole;slot:help + summary: "Available participant role values." + content: | + Choose the value that describes the record. Options: Organizer, Attendee, Optional. + - key: possibleValueSource:CrmScorableEntity;slot:help + summary: "Available scorable entity values." + content: | + Choose the value that describes the record. Options: Contact. + - key: possibleValueSource:CrmScoreOperator;slot:help + summary: "Available score operator values." + content: | + Choose the value that describes the record. Options: Equals, Not Equals, Contains, Greater Than, Less Than, Is Set, Is Not Set, Days Ago Greater Than, Days Ago Less Than. + - key: possibleValueSource:CrmSequenceStepType;slot:help + summary: "Available sequence step type values." + content: | + Choose the value that describes the record. Options: Email, Call, Task, Wait. + - key: possibleValueSource:CrmSyncStatus;slot:help + summary: "Available sync status values." + content: | + Choose the value that describes the record. Options: Synced, Pending, Error. + - key: possibleValueSource:CrmWinLossType;slot:help + summary: "Available win loss type values." + content: | + Choose the value that describes the record. Options: Win, Loss. + - key: process:crm_bulk_assign_owner;slot:help + summary: "Assign Owner for selected contact records." + content: | + Choose the user who will own the selected records. The review step shows the intended work before it runs. + - key: process:crm_auto_assign;slot:help + summary: "Auto-Assign for selected contact records." + content: | + Apply the configured assignment rules to the selected contacts. The review step shows the intended work before it runs. + - key: process:crm_subscribe_to_list;slot:help + summary: "Add to List for selected contact records." + content: | + Choose a list and review consent. Double opt-in lists require confirmation before membership becomes subscribed. The review step shows the intended work before it runs. + - key: process:crm_unsubscribe_from_list;slot:help + summary: "Remove from List for selected contact records." + content: | + Withdraw list membership for the selected contacts. This records the unsubscribe. The review step shows the intended work before it runs. + - key: process:crm_enroll_in_sequence;slot:help + summary: "Enroll in Sequence for selected contact records." + content: | + Enroll the selected contacts in a sequence after checking eligibility and timing. The review step shows the intended work before it runs. + - key: process:crm_unenroll_from_sequence;slot:help + summary: "Unenroll from Sequence for selected contact records." + content: | + Stop sequence progression for the selected contacts. The review step shows the intended work before it runs. + - key: process:crm_log_activity;slot:help + summary: "Log Activity for selected contact records." + content: | + Record an interaction for the selected records. Select an activity type and matching outcome. The review step shows the intended work before it runs. + - key: process:crm_recalculate_score;slot:help + summary: "Recalculate Score for selected contact records." + content: | + Recalculate scores from the configured lead-score rules. The review step shows the intended work before it runs. + - key: process:crm_merge_contacts;slot:help + summary: "Merge Contacts for selected contact records." + content: | + Select exactly two records and choose the survivor. Related records move to the survivor; review carefully before confirming. The review step shows the intended work before it runs. + - key: process:crm_detect_duplicates;slot:help + summary: "Find Duplicates for selected contact records." + content: | + Find potential duplicates for review. This action does not decide which record should survive a merge. The review step shows the intended work before it runs. + - key: process:crm_convert_lead;slot:help + summary: "Create Deal for selected contact records." + content: | + Create a sales opportunity from the selected contact, with a pipeline and stage. The review step shows the intended work before it runs. + - key: process:crm_gdpr_anonymize_contact;slot:help + summary: "Erase Personal Data for selected contact records." + content: | + Permanently anonymize the selected contacts and related personal data, redact audit details and stop sequences. This requires CRM admin access. Review the selection before confirming. The review step shows the intended work before it runs. + - key: process:crm_send_email;slot:help + summary: "Send Email for selected contact records." + content: | + Send a message through the configured transport. Review the recipient, subject and content; marketing consent is enforced. The review step shows the intended work before it runs. + - key: process:crm_assign_company_owner;slot:help + summary: "Assign Owner for selected company records." + content: | + Choose the user who will own the selected records. The review step shows the intended work before it runs. + - key: process:crm_merge_companies;slot:help + summary: "Merge Companies for selected company records." + content: | + Select exactly two records and choose the survivor. Related records move to the survivor; review carefully before confirming. The review step shows the intended work before it runs. + - key: process:crm_find_duplicate_companies;slot:help + summary: "Find Duplicates for selected company records." + content: | + Find potential duplicates for review. This action does not decide which record should survive a merge. The review step shows the intended work before it runs. + - key: process:crm_log_company_activity;slot:help + summary: "Log Activity for selected company records." + content: | + Record an interaction for the selected records. Select an activity type and matching outcome. The review step shows the intended work before it runs. + - key: process:crm_move_deal_stage;slot:help + summary: "Move Stage for selected deal records." + content: | + Choose a stage in the current pipeline. Required fields and stage membership are validated. The review step shows the intended work before it runs. + - key: process:crm_mark_deal_won;slot:help + summary: "Mark Won for selected deal records." + content: | + Move the selected deals to a won stage and record the close date. The review step shows the intended work before it runs. + - key: process:crm_mark_deal_lost;slot:help + summary: "Mark Lost for selected deal records." + content: | + Move the selected deals to a lost stage and record why they were lost. The review step shows the intended work before it runs. + - key: process:crm_reopen_deal;slot:help + summary: "Reopen for selected deal records." + content: | + Return closed deals to an open stage and clear the close date. The review step shows the intended work before it runs. + - key: process:crm_clone_deal;slot:help + summary: "Clone for selected deal records." + content: | + Create a new opportunity from an existing deal. Review the copied values before using the new record. The review step shows the intended work before it runs. + - key: process:crm_transfer_ownership;slot:help + summary: "Assign Owner for selected deal records." + content: | + Choose the user who will own the selected records. The review step shows the intended work before it runs. + - key: process:crm_manage_contact_on_deal;slot:help + summary: "Add Contact for selected deal records." + content: | + Link an existing contact to the selected deal and identify their role. The review step shows the intended work before it runs. + - key: process:crm_log_deal_activity;slot:help + summary: "Log Activity for selected deal records." + content: | + Record an interaction for the selected records. Select an activity type and matching outcome. The review step shows the intended work before it runs. + - key: process:crm_convert_form_to_contact;slot:help + summary: "Convert to Contact for selected form submission records." + content: | + Link a web lead to a contact or create a new contact. Review the submitted email and consent evidence. The review step shows the intended work before it runs. + - key: process:crm_activate_sequence;slot:help + summary: "Activate for selected email sequence records." + content: | + Make a reviewed sequence available to run. Confirm the steps and email transport are ready. The review step shows the intended work before it runs. + - key: process:crm_pause_sequence;slot:help + summary: "Pause for selected email sequence records." + content: | + Pause sequence execution while retaining enrollments and progress. The review step shows the intended work before it runs. + - key: process:crm_process_sequence_steps;slot:help + summary: "Advance due active sequence enrollments." + content: | + Advance due active sequence enrollments. Email steps require consent and a ready transport. + - key: process:crm_calculate_lead_scores;slot:help + summary: "Refresh contact and company lead scores from enabled rules and save the successful-run checkpoint." + content: | + Refresh contact and company lead scores from enabled rules and save the successful-run checkpoint. + - key: process:crm_sync_email_delivery;slot:help + summary: "Refresh CRM delivery states from the configured email provider." + content: | + Refresh CRM delivery states from the configured email provider. + - key: process:crm_expire_consents;slot:help + summary: "Record expired consent so marketing eligibility reflects the current state." + content: | + Record expired consent so marketing eligibility reflects the current state. + - key: process:crm_job_state.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_job_state.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_job_state.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_job_state.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_external_mapping.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_external_mapping.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_external_mapping.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_external_mapping.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_assignment_rule.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_assignment_rule.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_assignment_rule.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_assignment_rule.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_assignment_rule_member.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_assignment_rule_member.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_assignment_rule_member.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_assignment_rule_member.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_lead_score_rule.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_lead_score_rule.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_lead_score_rule.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_lead_score_rule.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_sales_goal.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_sales_goal.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_sales_goal.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_sales_goal.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_email_message.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_email_message.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_email_message.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_email_message.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_email_sequence.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_email_sequence.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_email_sequence.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_email_sequence.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_email_template.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_email_template.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_email_template.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_email_template.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_sequence_enrollment.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_sequence_enrollment.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_sequence_enrollment.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_sequence_enrollment.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_sequence_step.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_sequence_step.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_sequence_step.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_sequence_step.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_contact_role.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_contact_role.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_contact_role.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_contact_role.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_deal.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_deal.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_deal.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_deal.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_deal_contact.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_deal_contact.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_deal_contact.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_deal_contact.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_deal_product.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_deal_product.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_deal_product.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_deal_product.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_deal_stage_history.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_deal_stage_history.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_deal_stage_history.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_deal_stage_history.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_pipeline.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_pipeline.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_pipeline.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_pipeline.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_pipeline_stage.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_pipeline_stage.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_pipeline_stage.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_pipeline_stage.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_product.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_product.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_product.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_product.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_win_loss_reason.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_win_loss_reason.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_win_loss_reason.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_win_loss_reason.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_company.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_company.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_company.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_company.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_consent_record.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_consent_record.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_consent_record.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_consent_record.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_contact.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_contact.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_contact.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_contact.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_currency.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_currency.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_currency.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_currency.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_email_list.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_email_list.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_email_list.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_email_list.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_email_list_member.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_email_list_member.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_email_list_member.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_email_list_member.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_form_submission.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_form_submission.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_form_submission.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_form_submission.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_industry.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_industry.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_industry.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_industry.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_lead_source.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_lead_source.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_lead_source.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_lead_source.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_lifecycle_stage.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_lifecycle_stage.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_lifecycle_stage.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_lifecycle_stage.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_activity.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_activity.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_activity.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_activity.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_activity_outcome.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_activity_outcome.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_activity_outcome.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_activity_outcome.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_activity_participant.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_activity_participant.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_activity_participant.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_activity_participant.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. + - key: process:crm_activity_type.bulkInsert;slot:help + summary: "Import new records from a file." + content: | + Upload a file, select or create a mapping, and review validation results before inserting records. Use the CRM starter profiles for contacts, companies and deals when available. Imports use the same permissions and tenant locks as record screens. + - key: process:crm_activity_type.bulkEdit;slot:help + summary: "Apply a change to selected records." + content: | + Select the records to change, set the intended field values and review the result before confirming. Relationship validation, consent immutability and tenant locks still apply. + - key: process:crm_activity_type.bulkEditWithFile;slot:help + summary: "Update existing records using a file." + content: | + Upload the changes and choose fields that identify existing records. Review matches and validation errors before confirming. Only records accessible to your session can be changed. + - key: process:crm_activity_type.bulkDelete;slot:help + summary: "Delete the selected records." + content: | + Review the selected records before confirming deletion. Deletion is not a substitute for Erase Personal Data on contacts, which also redacts related personal copies and audit details. Consent records cannot be deleted. diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmPublicationHelpTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmPublicationHelpTest.java new file mode 100644 index 0000000..2551573 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmPublicationHelpTest.java @@ -0,0 +1,152 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn; +import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction; +import com.kingsrook.qqq.api.model.APIVersion; +import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer; +import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Published fields, search declaration and shipped help coverage. + *******************************************************************************/ +class CrmPublicationHelpTest extends BaseTest +{ + /******************************************************************************* + ** Api matches every published table. + *******************************************************************************/ + @Test + void apiMatchesEveryPublishedTable() throws Exception + { + QInstance instance = P4TestSupport.host(); + instance.withSupplementalMetaData(new ApiInstanceMetaDataContainer().withApiInstanceMetaData(new ApiInstanceMetaData() + .withName("host").withPath("/api/").withLabel("Host API").withDescription("CRM test API").withContactEmail("test@example.invalid").withCurrentVersion(new APIVersion("v1")).withSupportedVersions(List.of(new APIVersion("v1"))))); + new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME) + .withApiName("host").withApiVersion("v1")).produce(instance).addSelfToInstance(instance); + new QInstanceValidator().validate(instance); + QContext.setQInstance(instance); + GetTableApiFieldsAction.clearCaches(); + List published = new ArrayList<>(CrmAppMetaData.MAIN_TABLES); + published.addAll(CrmAppMetaData.SETTINGS_TABLES); + for(String name : published) + { + var table = instance.getTable(name); + assertThat(ApiTableMetaDataContainer.of(table).getApiTableMetaData("host")).isNotNull(); + var fields = GetTableApiFieldsAction.getTableApiFieldMap(new GetTableApiFieldsInput().withApiName("host").withVersion("v1").withTableName(name)); + assertThat(fields.keySet()).as(name).containsExactlyInAnyOrderElementsOf(table.getFields().keySet()); + } + assertThat(ApiTableMetaDataContainer.of(instance.getTable("crm_deal_product"))).isNull(); + GetTableApiFieldsAction.clearCaches(); + } + + + /******************************************************************************* + ** Esb fails closed until core excludes classified values. + *******************************************************************************/ + @Test + void esbFailsClosedUntilCoreExcludesClassifiedValues() + { + assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig() + .withDefaultBackendNameForTables(BACKEND_NAME).withEsbDestinationName("crmEvents")).produce(P4TestSupport.host())) + .hasMessageContaining("classified-value exclusion"); + } + + + /******************************************************************************* + ** Main tables declare search fields. + *******************************************************************************/ + @Test + void mainTablesDeclareSearchFields() + { + for(String table : CrmAppMetaData.MAIN_TABLES) + { + assertThat(QContext.getQInstance().getTable(table).getSearchFields()).isNotEmpty(); + } + assertThat(QContext.getQInstance().getTable("crm_contact").getSearchFields()).containsExactly("firstName", "lastName", "email"); + assertThat(QContext.getQInstance().getTable("crm_company").getSearchFields()).containsExactly("name", "domain"); + } + + + /******************************************************************************* + ** Help covers app every table main field process and enum. + *******************************************************************************/ + @Test + void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception + { + var entries = CrmHelpBundleStandIn.entries(); + var instance = QContext.getQInstance(); + assertThat(entries).containsKey("app:crm;slot:help"); + for(var table : instance.getTables().values()) + { + assertThat(entries).containsKey("table:" + table.getName() + ";slot:help"); + assertThat(table.getHelpContent().get("help")).isNotEmpty(); + if(CrmAppMetaData.MAIN_TABLES.contains(table.getName())) + { + for(var field : table.getFields().values()) + { + assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName()); + assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty(); + } + } + } + for(var process : instance.getProcesses().values()) + { + if(process.getName().startsWith("crm_")) + { + assertThat(entries).containsKey("process:" + process.getName() + ";slot:help"); + // Core creates dotted bulk-process names after QBit production; they retain core screen help. + if(!process.getName().contains(".")) + { + process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast) + .forEach(step -> assertThat(step.getHelpContents()).isNotEmpty()); + } + } + } + for(var pvs : instance.getPossibleValueSources().values()) + { + if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm")) + { + assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help"); + } + } + entries.values().forEach(entry -> + { + assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank(); + assertThat(entry.get("content").asText()).isNotBlank(); + }); + } +} + -- 2.54.0 From 019d93a5d7f43125f2992f7574bdfccec51fd982 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:54:53 -0500 Subject: [PATCH 2/8] feat: CRM health checks (stand-in qqq#1199) and bulk-load starter profiles Co-Authored-By: Claude Opus 5.5 --- .../crm/bulkload/CrmBulkLoadProfiles.java | 86 ++++++++ .../qbits/crm/health/CrmHealthChecks.java | 192 ++++++++++++++++++ src/main/resources/bulkload/crm-profiles.json | 128 ++++++++++++ .../qbits/crm/CrmHealthBulkLoadTest.java | 189 +++++++++++++++++ 4 files changed, 595 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/crm/bulkload/CrmBulkLoadProfiles.java create mode 100644 src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java create mode 100644 src/main/resources/bulkload/crm-profiles.json create mode 100644 src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/bulkload/CrmBulkLoadProfiles.java b/src/main/java/com/kingsrook/qbits/crm/bulkload/CrmBulkLoadProfiles.java new file mode 100644 index 0000000..6b2e467 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/bulkload/CrmBulkLoadProfiles.java @@ -0,0 +1,86 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm.bulkload; + + +import java.io.IOException; +import java.io.InputStream; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.crm.core.CrmRecords; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfile; +import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile; + + +/******************************************************************************* + ** Starter mappings for the host's core bulk loader. The host owns its tables. + *******************************************************************************/ +public final class CrmBulkLoadProfiles +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CrmBulkLoadProfiles() + { + } + + + /******************************************************************************* + ** Call once during host startup, after core profile tables are installed. + ** Existing profiles, including user edits, are preserved by label. + *******************************************************************************/ + public static void ensure() throws QException + { + if(QContext.getQInstance().getTable(SavedBulkLoadProfile.TABLE_NAME) == null) + { + return; + } + try(InputStream stream = CrmBulkLoadProfiles.class.getResourceAsStream("/bulkload/crm-profiles.json")) + { + ObjectMapper mapper = new ObjectMapper(); + for(JsonNode profile : mapper.readTree(stream)) + { + String label = profile.get("label").asText(); + if(!new QueryAction().execute(new QueryInput(SavedBulkLoadProfile.TABLE_NAME).withFilter(new QQueryFilter( + new QFilterCriteria("label", QCriteriaOperator.EQUALS, label)).withLimit(1))).getRecords().isEmpty()) + { + continue; + } + BulkLoadProfile mapping = mapper.treeToValue(profile.get("mapping"), BulkLoadProfile.class); + CrmRecords.insert(SavedBulkLoadProfile.TABLE_NAME, new QRecord().withValue("label", label) + .withValue("tableName", profile.get("tableName").asText()).withValue("isBulkEdit", false) + .withValue("mappingJson", mapper.writeValueAsString(mapping))); + } + } + catch(IOException | IllegalArgumentException e) + { + throw (new QException("Could not load CRM bulk-load profiles", e)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java new file mode 100644 index 0000000..7ff3d04 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java @@ -0,0 +1,192 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm.health; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.crm.CrmQBitConfig; +import com.kingsrook.qbits.crm.api.CrmReadiness; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.email.CrmEmailTransport; +import com.kingsrook.qbits.crm.scoring.JobState; +import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.middleware.health.HealthIndicator; +import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult; +import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; + + +/******************************************************************************* + ** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9) + ** CRM checks for hosts to register with their health middleware. Run in the + ** caller's tenant context; no check elevates access or returns personal values. + *******************************************************************************/ +public class CrmHealthChecks +{ + private final CrmEmailTransport transport; + + + /******************************************************************************* + ** Use the same readiness decision as actual email delivery. + *******************************************************************************/ + public CrmHealthChecks(CrmQBitConfig config) + { + this(new CrmEmailTransport(config)); + } + + + /******************************************************************************* + ** Allow a host transport adapter. + *******************************************************************************/ + public CrmHealthChecks(CrmEmailTransport transport) + { + this.transport = transport; + } + + + /******************************************************************************* + ** Run all checks without registering tables or starting background workers. + *******************************************************************************/ + public Map run() throws QException + { + return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore())); + } + + + /******************************************************************************* + ** Expose individual checks through the RC.6 health SPI. + *******************************************************************************/ + public List indicators() + { + return (indicators(QContext.getQSession() == null ? new QSession() : QContext.getQSession())); + } + + + /******************************************************************************* + ** Supply the host's monitoring session explicitly for middleware worker threads. + ** Its permissions and tenant keys are preserved; this adapter never grants keys. + *******************************************************************************/ + public List indicators(QSession session) + { + return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session))); + } + + + /******************************************************************************* + ** One independently evaluated middleware indicator. + *******************************************************************************/ + private class Indicator implements HealthIndicator + { + private final String name; + private final QSession session; + + + /******************************************************************************* + ** Select a check. + *******************************************************************************/ + private Indicator(String name, QSession session) + { + this.name = name; + this.session = session; + } + + + @Override + public String getName() + { + return (name); + } + + + @Override + public HealthCheckResult check(QInstance instance) + { + CapturedContext original = QContext.capture(); + try + { + QContext.init(instance, session); + return (switch(name) + { + case "crm.email" -> email(); + case "crm.sequenceLag" -> sequenceLag(); + default -> leadScore(); + }); + } + catch(QException | RuntimeException e) + { + return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed")); + } + finally + { + QContext.init(original); + } + } + } + + + /******************************************************************************* + ** An unconfigured transport is degraded, not a claim that email can be sent. + *******************************************************************************/ + private HealthCheckResult email() throws QException + { + return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP)); + } + + + /******************************************************************************* + ** Query just the oldest active due enrollment. + *******************************************************************************/ + private HealthCheckResult sequenceLag() throws QException + { + List rows = new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId())) + .withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN, Instant.now().minus(30, ChronoUnit.MINUTES))) + .withOrderBy(new QFilterOrderBy("nextStepDate")).withLimit(1))).getRecords(); + return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED)); + } + + + /******************************************************************************* + ** No successful score pass, or one more than three hours old, needs attention. + *******************************************************************************/ + private HealthCheckResult leadScore() throws QException + { + List rows = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME).withFilter(new QQueryFilter( + new QFilterCriteria("name", QCriteriaOperator.EQUALS, CalculateLeadScoresProcess.NAME)).withLimit(1))).getRecords(); + Instant last = rows.isEmpty() ? null : rows.getFirst().getValueInstant("lastSuccess"); + return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS)) + ? HealthStatus.DEGRADED : HealthStatus.UP)); + } +} diff --git a/src/main/resources/bulkload/crm-profiles.json b/src/main/resources/bulkload/crm-profiles.json new file mode 100644 index 0000000..2cd7ac3 --- /dev/null +++ b/src/main/resources/bulkload/crm-profiles.json @@ -0,0 +1,128 @@ +[ + { + "label": "CRM Contacts - Generic CSV", + "tableName": "crm_contact", + "mapping": { + "version": "1", + "layout": "FLAT", + "hasHeaderRow": true, + "isBulkEdit": false, + "fieldList": [ + { + "fieldName": "firstName", + "headerName": "First Name", + "columnIndex": 0 + }, + { + "fieldName": "lastName", + "headerName": "Last Name", + "columnIndex": 1 + }, + { + "fieldName": "email", + "headerName": "Email", + "columnIndex": 2 + }, + { + "fieldName": "phone", + "headerName": "Phone", + "columnIndex": 3 + }, + { + "fieldName": "companyId", + "headerName": "Company", + "columnIndex": 4 + }, + { + "fieldName": "jobTitle", + "headerName": "Job Title", + "columnIndex": 5 + } + ] + } + }, + { + "label": "CRM Companies - Generic CSV", + "tableName": "crm_company", + "mapping": { + "version": "1", + "layout": "FLAT", + "hasHeaderRow": true, + "isBulkEdit": false, + "fieldList": [ + { + "fieldName": "name", + "headerName": "Company", + "columnIndex": 0 + }, + { + "fieldName": "domain", + "headerName": "Domain", + "columnIndex": 1 + }, + { + "fieldName": "phone", + "headerName": "Phone", + "columnIndex": 2 + }, + { + "fieldName": "addressLine1", + "headerName": "Address", + "columnIndex": 3 + }, + { + "fieldName": "city", + "headerName": "City", + "columnIndex": 4 + }, + { + "fieldName": "postalCode", + "headerName": "Postal Code", + "columnIndex": 5 + } + ] + } + }, + { + "label": "CRM Deals - Generic CSV", + "tableName": "crm_deal", + "mapping": { + "version": "1", + "layout": "FLAT", + "hasHeaderRow": true, + "isBulkEdit": false, + "fieldList": [ + { + "fieldName": "name", + "headerName": "Deal", + "columnIndex": 0 + }, + { + "fieldName": "companyId", + "headerName": "Company", + "columnIndex": 1 + }, + { + "fieldName": "pipelineId", + "headerName": "Pipeline", + "columnIndex": 2 + }, + { + "fieldName": "pipelineStageId", + "headerName": "Stage", + "columnIndex": 3 + }, + { + "fieldName": "amount", + "headerName": "Amount", + "columnIndex": 4 + }, + { + "fieldName": "currencyCode", + "headerName": "Currency", + "columnIndex": 5 + } + ] + } + } +] diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java new file mode 100644 index 0000000..408125a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java @@ -0,0 +1,189 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.crm.api.CrmReadiness; +import com.kingsrook.qbits.crm.bulkload.CrmBulkLoadProfiles; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.email.CrmEmailTransport; +import com.kingsrook.qbits.crm.health.CrmHealthChecks; +import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider; +import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile; +import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Maintenance health thresholds and host-owned bulk-load profile seeding. + *******************************************************************************/ +class CrmHealthBulkLoadTest extends BaseTest +{ + /******************************************************************************* + ** No transport or score run needs attention. + *******************************************************************************/ + @Test + void noTransportOrScoreRunNeedsAttention() throws Exception + { + var checks = new CrmHealthChecks(new CrmQBitConfig()); + var results = checks.run(); + assertThat(results.get("crm.email").getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(results.get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(results.get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.UP); + checks.indicators().forEach(indicator -> + { + try + { + assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus()); + } + catch(Exception e) + { + throw (new AssertionError(e)); + } + }); + } + + + /******************************************************************************* + ** Healthy then late jobs. + *******************************************************************************/ + @Test + void healthyThenLateJobs() throws Exception + { + CrmEmailTransport transport = new CrmEmailTransport(new CrmQBitConfig()) + { + @Override + public CrmReadiness readiness() + { + return (CrmReadiness.CORE_MESSAGING); + } + }; + CrmHealthChecks checks = new CrmHealthChecks(transport); + P4TestSupport.seed("crm_job_state", new QRecord().withValue("id", 1).withValue("name", CalculateLeadScoresProcess.NAME) + .withValue("lastSuccess", Instant.now().minus(2, ChronoUnit.HOURS))); + P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("status", CrmEnrollmentStatus.ACTIVE.getId()) + .withValue("nextStepDate", Instant.now().minus(29, ChronoUnit.MINUTES)), + new QRecord().withValue("id", 2).withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()) + .withValue("nextStepDate", Instant.now().minus(2, ChronoUnit.DAYS))); + assertThat(checks.run().values()).allMatch(result -> result.getStatus() == HealthStatus.UP); + com.kingsrook.qbits.crm.core.CrmRecords.update("crm_job_state", new QRecord().withValue("id", 1) + .withValue("lastSuccess", Instant.now().minus(181, ChronoUnit.MINUTES))); + com.kingsrook.qbits.crm.core.CrmRecords.update("crm_sequence_enrollment", new QRecord().withValue("id", 1) + .withValue("nextStepDate", Instant.now().minus(31, ChronoUnit.MINUTES))); + assertThat(checks.run().get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(checks.run().get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED); + } + + + /******************************************************************************* + ** Health failure reports unknown. + *******************************************************************************/ + @Test + void healthFailureReportsUnknown() throws Exception + { + CrmHealthChecks checks = new CrmHealthChecks(new CrmEmailTransport(new CrmQBitConfig()) + { + @Override + public CrmReadiness readiness() + { + throw (new IllegalStateException("unavailable")); + } + }); + assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN); + } + + + /******************************************************************************* + ** Profiles are optional idempotent and valid core mappings. + *******************************************************************************/ + @Test + void profilesAreOptionalIdempotentAndValidCoreMappings() throws Exception + { + CrmBulkLoadProfiles.ensure(); + assertThat(QContext.getQInstance().getTable("savedBulkLoadProfile")).isNull(); + var table = new SavedBulkLoadProfileMetaDataProvider().defineSavedBulkLoadProfileTable(BACKEND_NAME, ignored -> + { + // Host has no additional backend details. + }); + QContext.getQInstance().addTable(table); + CrmBulkLoadProfiles.ensure(); + CrmBulkLoadProfiles.ensure(); + var rows = new QueryAction().execute(new QueryInput("savedBulkLoadProfile")).getRecords(); + assertThat(rows).hasSize(3); + for(QRecord row : rows) + { + BulkLoadProfile profile = new ObjectMapper().readValue(row.getValueString("mappingJson"), BulkLoadProfile.class); + assertThat(profile.getHasHeaderRow()).isTrue(); + assertThat(profile.getLayout()).isEqualTo("FLAT"); + assertThat(profile.getFieldList()).isNotEmpty(); + profile.getFieldList().forEach(field -> assertThat(QContext.getQInstance().getTable(row.getValueString("tableName")).getField(field.getFieldName())).isNotNull()); + if(row.getValueString("tableName").equals("crm_contact")) + { + assertThat(profile.getFieldList().stream().map(field -> field.getHeaderName())).containsExactlyElementsOf( + List.of("First Name", "Last Name", "Email", "Phone", "Company", "Job Title")); + } + } + QRecord first = rows.getFirst(); + com.kingsrook.qbits.crm.core.CrmRecords.update("savedBulkLoadProfile", new QRecord().withValue("id", first.getValue("id")) + .withValue("mappingJson", "{\"hostEdited\":true}")); + CrmBulkLoadProfiles.ensure(); + assertThat(com.kingsrook.qqq.backend.core.actions.tables.GetAction.execute("savedBulkLoadProfile", first.getValue("id")) + .getValueString("mappingJson")).contains("hostEdited"); + } + + /******************************************************************************* + ** Health middleware invokes checks in worker threads without a caller QContext. + *******************************************************************************/ + @Test + void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception + { + var instance = QContext.getQInstance(); + var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1); + var status = java.util.concurrent.CompletableFuture.supplyAsync(() -> + { + try + { + assertThat(QContext.getQInstance()).isNull(); + var result = indicator.check(instance); + assertThat(QContext.getQInstance()).isNull(); + assertThat(QContext.getQSession()).isNull(); + return (result.getStatus()); + } + catch(Exception e) + { + throw (new AssertionError(e)); + } + }).get(); + assertThat(status).isEqualTo(HealthStatus.UP); + assertThat(QContext.getQInstance()).isSameAs(instance); + } + +} -- 2.54.0 From 7ee43485c3c9af6536f48659999b10143a9237e0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:54:54 -0500 Subject: [PATCH 3/8] feat(privacy): Erase Personal Data covers every CRM table and core audit copies Co-Authored-By: Claude Opus 5.5 --- .../GdprAnonymizeContactProcess.java | 125 +-------- .../qbits/crm/privacy/CrmPersonalData.java | 247 ++++++++++++++++++ .../qbits/crm/CrmPersonalDataTest.java | 143 ++++++++++ 3 files changed, 394 insertions(+), 121 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/crm/privacy/CrmPersonalData.java create mode 100644 src/test/java/com/kingsrook/qbits/crm/CrmPersonalDataTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java index aab5a64..d231731 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java @@ -18,147 +18,30 @@ * limitations under the License. */ - -/******************************************************************************* - ** GDPR right-to-erasure process. Anonymizes PII on a contact, scrubs audit - ** log entries, deletes attachments, unenrolls from sequences, creates a - ** consent-withdrawn record, and logs the anonymization event. - *******************************************************************************/ - package com.kingsrook.qbits.crm.core.processes; -import java.time.Instant; -import com.kingsrook.qbits.crm.core.model.ConsentRecord; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; -import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; -import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.privacy.CrmPersonalData; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; -import com.kingsrook.qqq.backend.core.actions.tables.GetAction; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* - ** Backend operation used by the streamed CRM actions for GDPR anonymization of a contact. + ** Backend operation for the streamed Erase Personal Data action. *******************************************************************************/ public class GdprAnonymizeContactProcess implements BackendStep { - private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class); - public static final String NAME = "crm_gdpr_anonymize_contact"; - private static final String ANONYMIZED = "[ANONYMIZED]"; - - - /******************************************************************************* - ** Execute the GDPR anonymization process. - *******************************************************************************/ @Override public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException { - Integer contactId = input.getValueInteger("contactId"); - - /////////////////////////////////////////// - // Step 1: load and validate contact // - /////////////////////////////////////////// - QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId); - if(contactRecord == null) - { - throw new QException("Contact not found: " + contactId); - } - - /////////////////////////////////////////// - // Step 2: anonymize PII fields // - /////////////////////////////////////////// - QRecord updateRecord = new QRecord() - .withValue("id", contactId) - .withValue("firstName", ANONYMIZED) - .withValue("lastName", ANONYMIZED) - .withValue("email", "anonymized-" + contactId + "@removed.invalid") - .withValue("phone", null) - .withValue("mobilePhone", null) - .withValue("secondaryEmail", null) - .withValue("addressLine1", null) - .withValue("addressLine2", null) - .withValue("city", null) - .withValue("state", null) - .withValue("postalCode", null) - .withValue("country", null) - .withValue("website", null) - .withValue("linkedinUrl", null) - .withValue("description", null) - .withValue("doNotEmail", true) - .withValue("doNotCall", true); - - new UpdateAction().execute( - new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord)); - - /////////////////////////////////////////// - // Step 3: unenroll from sequences // - /////////////////////////////////////////// - unenrollFromSequences(contactId); - - /////////////////////////////////////////// - // Step 4: create consent record // - /////////////////////////////////////////// - new InsertAction().execute( - new InsertInput(ConsentRecord.TABLE_NAME).withRecordEntity( - new ConsentRecord() - .withContactId(contactId) - .withConsentType(CrmConsentType.DATA_PROCESSING.getPossibleValueId()) - .withStatus(CrmConsentStatus.WITHDRAWN.getPossibleValueId()) - .withSource("GDPR Anonymization Process") - .withConsentDate(Instant.now()))); - + Integer contactId = input.getValueInteger("contactId"); + CrmPersonalData.erase(contactId); output.addValue("contactId", contactId); output.addValue("anonymized", true); } - - - - /*************************************************************************** - ** Unenroll from any active sequence enrollments for this contact. - ***************************************************************************/ - private void unenrollFromSequences(Integer contactId) throws QException - { - try - { - QueryOutput enrollments = new QueryAction().execute( - new QueryInput("crm_sequence_enrollment") - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)) - .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))); - - for(QRecord record : enrollments.getRecords()) - { - QRecord updateRecord = new QRecord() - .withValue("id", record.getValueInteger("id")) - .withValue("status", CrmEnrollmentStatus.UNENROLLED.getPossibleValueId()); - - new UpdateAction().execute( - new UpdateInput("crm_sequence_enrollment").withRecord(updateRecord)); - } - } - catch(Exception e) - { - LOG.warn("Error unenrolling contact from sequences during GDPR anonymization", e); - } - } - } diff --git a/src/main/java/com/kingsrook/qbits/crm/privacy/CrmPersonalData.java b/src/main/java/com/kingsrook/qbits/crm/privacy/CrmPersonalData.java new file mode 100644 index 0000000..c67c1d0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/privacy/CrmPersonalData.java @@ -0,0 +1,247 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm.privacy; + + +import java.io.Serializable; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.crm.core.CrmRecords; +import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Erases the CRM's copies of a contact's personal data using tenant-scoped actions. + *******************************************************************************/ +public final class CrmPersonalData +{ + /******************************************************************************* + ** Narrow consent exception: only replacing evidence text with "erased". + *******************************************************************************/ + public enum ErasureFlag implements ActionFlag + { + REDACT_CONSENT + } + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CrmPersonalData() + { + } + + + /******************************************************************************* + ** Erase linked data before the contact so retries still have the original email. + *******************************************************************************/ + public static void erase(Integer contactId) throws QException + { + QRecord contact = GetAction.execute("crm_contact", contactId); + if(contact == null) + { + throw (new QException("Contact not found: " + contactId)); + } + List emails = new ArrayList<>(); + for(String field : List.of("email", "secondaryEmail")) + { + if(contact.getValueString(field) != null) + { + emails.add(contact.getValueString(field)); + } + } + // Two queries avoid OR filters crossing tenant scopes and make retries idempotent. + eraseForms(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))); + if(!emails.isEmpty()) + { + eraseForms(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.IN, emails))); + } + for(QRecord row : linked("crm_email_list_member", contactId)) + { + redact("crm_email_list_member", row, "email", "firstName", "confirmationTokenHash", "consentText"); + } + for(QRecord row : linked("crm_email_message", contactId)) + { + redact("crm_email_message", row, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText"); + } + for(QRecord row : linked("crm_activity", contactId)) + { + redact("crm_activity", row, "description", "subject"); + for(QRecord message : query("crm_email_message", new QQueryFilter(new QFilterCriteria("activityId", QCriteriaOperator.EQUALS, row.getValue("id"))))) + { + redact("crm_email_message", message, "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText"); + } + } + for(QRecord row : linked("crm_consent_record", contactId)) + { + checkedUpdate("crm_consent_record", new QRecord().withValue("id", row.getValue("id")).withValue("consentText", "erased")); + redactAudit("crm_consent_record", row.getValue("id")); + } + for(QRecord row : linked("crm_sequence_enrollment", contactId)) + { + checkedUpdate("crm_sequence_enrollment", new QRecord().withValue("id", row.getValue("id")) + .withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()).withValue("nextStepDate", null)); + redactAudit("crm_sequence_enrollment", row.getValue("id")); + } + for(QRecord row : query("crm_external_mapping", new QQueryFilter( + new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getId()), + new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId)))) + { + var deleted = new DeleteAction().execute(new DeleteInput("crm_external_mapping").withPrimaryKey(row.getValue("id")).withOmitDmlAudit(true)); + if(deleted.getDeletedRecordCount() != 1) + { + throw (new QException("CRM erase could not delete an external mapping")); + } + redactAudit("crm_external_mapping", row.getValue("id")); + } + redact("crm_contact", contact, "firstName", "lastName", "phone", "mobilePhone", "secondaryEmail", "addressLine1", "addressLine2", + "city", "state", "postalCode", "country", "timezone", "jobTitle", "department", "website", "linkedinUrl", "description"); + checkedUpdate("crm_contact", new QRecord().withValue("id", contactId).withValue("email", "anonymized-" + contactId + "@removed.invalid") + .withValue("doNotEmail", true).withValue("doNotCall", true)); + CrmRecords.insert("crm_consent_record", new QRecord().withValue("contactId", contactId) + .withValue("consentType", CrmConsentType.DATA_PROCESSING.getId()).withValue("status", CrmConsentStatus.WITHDRAWN.getId()) + .withValue("consentText", "Erased on " + LocalDate.now(ZoneOffset.UTC)).withValue("source", "GDPR erasure").withValue("consentDate", Instant.now())); + } + + + /******************************************************************************* + ** Erase both direct and email-matched web leads. + *******************************************************************************/ + private static void eraseForms(QQueryFilter filter) throws QException + { + for(QRecord row : query("crm_form_submission", filter)) + { + redact("crm_form_submission", row, "firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"); + } + } + + + /******************************************************************************* + ** Collect rows through core record locks, never through a backend bypass. + *******************************************************************************/ + private static List linked(String table, Integer contactId) throws QException + { + return (query(table, new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)))); + } + + + /******************************************************************************* + ** Page queries to avoid the backend's default result limit silently omitting PII. + *******************************************************************************/ + private static List query(String table, QQueryFilter filter) throws QException + { + List result = new ArrayList<>(); + Serializable after = null; + while(true) + { + QQueryFilter page = filter.clone().withOrderBy(new QFilterOrderBy("id")).withLimit(250); + if(after != null) + { + page.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after)); + } + List rows = new QueryAction().execute(new QueryInput(table).withFilter(page)).getRecords(); + result.addAll(rows); + if(rows.size() < 250) + { + return (result); + } + after = rows.getLast().getValue("id"); + } + } + + + /******************************************************************************* + ** Keep required fields nonempty; never audit the original values during erasure. + *******************************************************************************/ + private static void redact(String table, QRecord row, String... fields) throws QException + { + QRecord update = new QRecord().withValue("id", row.getValue("id")); + for(String field : fields) + { + boolean required = Boolean.TRUE.equals(QContext.getQInstance().getTable(table).getField(field).getIsRequired()); + update.withValue(field, required || field.equals("firstName") || field.equals("lastName") ? "[ANONYMIZED]" : null); + } + checkedUpdate(table, update); + redactAudit(table, row.getValue("id")); + } + + + /******************************************************************************* + ** A failed write must fail erasure; never report a partially erased contact as done. + *******************************************************************************/ + private static void checkedUpdate(String table, QRecord update) throws QException + { + var output = new UpdateAction().execute(new UpdateInput(table).withRecord(update).withOmitDmlAudit(true).withFlag(ErasureFlag.REDACT_CONSENT)); + if(output.getRecords().isEmpty() || output.getRecords().stream().anyMatch(row -> row.getErrors() != null && !row.getErrors().isEmpty())) + { + throw (new QException("CRM erase could not redact " + table)); + } + } + + + /******************************************************************************* + ** RC.6 core audits resolve table ids through auditTable. Redact both values and + ** human-readable messages, which can embed old record labels and personal values. + *******************************************************************************/ + private static void redactAudit(String table, Serializable recordId) throws QException + { + if(QContext.getQInstance().getTable("auditTable") == null || QContext.getQInstance().getTable("audit") == null + || QContext.getQInstance().getTable("auditDetail") == null) + { + return; + } + for(QRecord auditTable : query("auditTable", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, table)))) + { + for(QRecord audit : query("audit", new QQueryFilter( + new QFilterCriteria("auditTableId", QCriteriaOperator.EQUALS, auditTable.getValue("id")), + new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, recordId)))) + { + for(QRecord detail : query("auditDetail", new QQueryFilter(new QFilterCriteria("auditId", QCriteriaOperator.EQUALS, audit.getValue("id"))))) + { + checkedUpdate("auditDetail", new QRecord().withValue("id", detail.getValue("id")) + .withValue("oldValue", "erased").withValue("newValue", "erased").withValue("message", "erased")); + } + checkedUpdate("audit", new QRecord().withValue("id", audit.getValue("id")).withValue("message", "Personal data erased")); + } + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmPersonalDataTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmPersonalDataTest.java new file mode 100644 index 0000000..b44c139 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmPersonalDataTest.java @@ -0,0 +1,143 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; +import com.kingsrook.qbits.crm.privacy.CrmPersonalData; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Seed all CRM copies and audit copies; verify no original personal strings survive. + *******************************************************************************/ +class CrmPersonalDataTest extends BaseTest +{ + /******************************************************************************* + ** Erasure covers all copies including audit values and messages. + *******************************************************************************/ + @Test + void erasureCoversAllCopiesIncludingAuditValuesAndMessages() throws Exception + { + new AuditsMetaDataProvider().defineStandardAuditTables(QContext.getQInstance(), BACKEND_NAME, ignored -> + { + // Host has no additional backend details. + }); + List originals = new ArrayList<>(); + QRecord contact = personal(new QRecord().withValue("id", 1), originals, "firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", + "addressLine1", "addressLine2", "city", "state", "postalCode", "country", "timezone", "jobTitle", "department", "website", "linkedinUrl", "description"); + P4TestSupport.seed("crm_contact", contact); + P4TestSupport.seed("crm_form_submission", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals, + "firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText"), + personal(new QRecord().withValue("id", 2), originals, "firstName", "lastName", "phone", "message", "clientHash", "visitorKey") + .withValue("email", contact.getValue("email"))); + P4TestSupport.seed("crm_email_list_member", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals, + "email", "firstName", "confirmationTokenHash", "consentText")); + P4TestSupport.seed("crm_activity", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals, "description", "subject")); + P4TestSupport.seed("crm_email_message", personal(new QRecord().withValue("id", 1).withValue("contactId", 1), originals, + "fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject", "bodyHtml", "bodyText"), + personal(new QRecord().withValue("id", 2).withValue("activityId", 1), originals, "fromAddress", "toAddresses", "subject")); + P4TestSupport.seed("crm_consent_record", personal(new QRecord().withValue("id", 1).withValue("contactId", 1) + .withValue("consentType", CrmConsentType.EMAIL.getId()).withValue("status", CrmConsentStatus.GRANTED.getId()), originals, "consentText")); + P4TestSupport.seed("crm_external_mapping", personal(new QRecord().withValue("id", 1).withValue("entityId", 1) + .withValue("entityType", CrmEntityType.CONTACT.getId()), originals, "externalId")); + P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 1).withValue("contactId", 1) + .withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("nextStepDate", Instant.now())); + int id = 1; + for(String table : List.of("crm_contact", "crm_form_submission", "crm_email_list_member", "crm_activity", "crm_email_message", + "crm_consent_record", "crm_external_mapping", "crm_sequence_enrollment")) + { + P4TestSupport.seed("auditTable", new QRecord().withValue("id", id).withValue("name", table)); + P4TestSupport.seed("audit", personal(new QRecord().withValue("id", (long) id).withValue("auditTableId", id).withValue("recordId", 1), originals, "message")); + P4TestSupport.seed("auditDetail", personal(new QRecord().withValue("id", (long) id).withValue("auditId", (long) id), originals, + "oldValue", "newValue", "message")); + id++; + } + CrmPersonalData.erase(1); + for(var table : QContext.getQInstance().getTables().values()) + { + for(QRecord row : new QueryAction().execute(new QueryInput(table.getName())).getRecords()) + { + for(Object value : row.getValues().values()) + { + if(value instanceof String text) + { + originals.forEach(original -> assertThat(text).as(table.getName() + ":" + row.getValue("id")).doesNotContain(original)); + } + } + } + } + assertThat(new QueryAction().execute(new QueryInput("crm_external_mapping")).getRecords()).isEmpty(); + assertThat(GetAction.execute("crm_sequence_enrollment", 1).getValueInteger("status")).isEqualTo(CrmEnrollmentStatus.UNENROLLED.getId()); + var consents = new QueryAction().execute(new QueryInput("crm_consent_record")).getRecords(); + assertThat(consents).hasSize(2); + assertThat(consents).anySatisfy(row -> + { + assertThat(row.getValueInteger("status")).isEqualTo(CrmConsentStatus.WITHDRAWN.getId()); + assertThat(row.getValueInteger("consentType")).isEqualTo(CrmConsentType.DATA_PROCESSING.getId()); + assertThat(row.getValueString("consentText")).startsWith("Erased on "); + }); + } + + + /******************************************************************************* + ** Missing contact fails and ordinary consent editing stays forbidden. + *******************************************************************************/ + @Test + void missingContactFailsAndOrdinaryConsentEditingStaysForbidden() throws Exception + { + assertThatThrownBy(() -> CrmPersonalData.erase(999)).hasMessageContaining("not found"); + P4TestSupport.seed("crm_consent_record", new QRecord().withValue("id", 1).withValue("contactId", 1).withValue("consentText", "original")); + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput("crm_consent_record") + .withRecord(new QRecord().withValue("id", 1).withValue("consentText", "erased")))).hasMessageContaining("cannot be modified"); + } + + + /******************************************************************************* + ** Distinct sentinels make missing field coverage visible without false matches. + *******************************************************************************/ + private QRecord personal(QRecord record, List originals, String... fields) + { + for(String field : fields) + { + String value = "pii-sentinel-" + originals.size() + "-" + field; + originals.add(value); + record.withValue(field, value); + } + return (record); + } +} -- 2.54.0 From b7b146259fdc43bfc7efd8b2e1711e2b1eb2fe3e Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:54:54 -0500 Subject: [PATCH 4/8] feat: contingent primary contact and server-side stage and outcome validation Co-Authored-By: Claude Opus 5.5 --- .../security/CrmRelationshipValidator.java | 86 +++++++++++++++++++ .../crm/CrmRelationshipValidationTest.java | 76 ++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/crm/security/CrmRelationshipValidator.java create mode 100644 src/test/java/com/kingsrook/qbits/crm/CrmRelationshipValidationTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/security/CrmRelationshipValidator.java b/src/main/java/com/kingsrook/qbits/crm/security/CrmRelationshipValidator.java new file mode 100644 index 0000000..cdb6fed --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/security/CrmRelationshipValidator.java @@ -0,0 +1,86 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm.security; + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; + + +/******************************************************************************* + ** CRM relationship invariants, enforced for inserts and partial updates. + *******************************************************************************/ +public class CrmRelationshipValidator implements TableCustomizerInterface +{ + @Override + public List preInsertOrUpdate(AbstractActionInput input, List records, boolean isPreview, + Optional> oldRecords) throws QException + { + Map oldById = new HashMap<>(); + oldRecords.orElse(List.of()).forEach(row -> oldById.put(row.getValue("id"), row)); + for(QRecord record : records) + { + QRecord effective = new QRecord(); + QRecord old = oldById.get(record.getValue("id")); + if(old != null) + { + effective.getValues().putAll(old.getValues()); + } + effective.getValues().putAll(record.getValues()); + if(effective.getValues().containsKey("pipelineStageId")) + { + validate(record, effective, "pipelineStageId", "pipelineId", "crm_pipeline_stage"); + } + if(effective.getValues().containsKey("outcomeId")) + { + validate(record, effective, "outcomeId", "activityTypeId", "crm_activity_outcome"); + } + } + return (records); + } + + + /******************************************************************************* + ** Missing optional choices are allowed; populated choices must belong to the parent. + *******************************************************************************/ + private void validate(QRecord record, QRecord effective, String childField, String parentField, String source) throws QException + { + if(effective.getValue(childField) == null) + { + return; + } + QRecord choice = GetAction.execute(source, effective.getValue(childField)); + if(choice == null || effective.getValue(parentField) == null + || !Objects.equals(choice.getValueInteger(parentField), effective.getValueInteger(parentField))) + { + record.addError(new BadInputStatusMessage("Choose a value belonging to the selected " + parentField + ".")); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmRelationshipValidationTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmRelationshipValidationTest.java new file mode 100644 index 0000000..3afeb18 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmRelationshipValidationTest.java @@ -0,0 +1,76 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Relationship validation must cover insert and either side of partial updates. + *******************************************************************************/ +class CrmRelationshipValidationTest extends BaseTest +{ + /******************************************************************************* + ** Rejects wrong pipeline on insert and partial updates. + *******************************************************************************/ + @Test + void rejectsWrongPipelineOnInsertAndPartialUpdates() throws Exception + { + P4TestSupport.seed("crm_pipeline_stage", new QRecord().withValue("id", 10).withValue("pipelineId", 1), + new QRecord().withValue("id", 20).withValue("pipelineId", 2)); + var inserted = new InsertAction().execute(new InsertInput("crm_deal").withRecord(new QRecord().withValue("name", "Wrong stage") + .withValue("pipelineId", 1).withValue("pipelineStageId", 20))); + assertThat(inserted.getRecords().getFirst().getErrors()).isNotEmpty(); + P4TestSupport.seed("crm_deal", new QRecord().withValue("id", 1).withValue("name", "Existing").withValue("pipelineId", 1).withValue("pipelineStageId", 10)); + for(QRecord change : List.of(new QRecord().withValue("id", 1).withValue("pipelineId", 2), + new QRecord().withValue("id", 1).withValue("pipelineStageId", 20))) + { + assertThat(new UpdateAction().execute(new UpdateInput("crm_deal").withRecord(change)).getRecords().getFirst().getErrors()).isNotEmpty(); + } + } + + + /******************************************************************************* + ** Validates activity outcome and allows null outcome. + *******************************************************************************/ + @Test + void validatesActivityOutcomeAndAllowsNullOutcome() throws Exception + { + P4TestSupport.seed("crm_activity_outcome", new QRecord().withValue("id", 10).withValue("activityTypeId", 1)); + var bad = new InsertAction().execute(new InsertInput("crm_activity").withRecord(new QRecord().withValue("subject", "Wrong outcome") + .withValue("activityTypeId", 2).withValue("outcomeId", 10))); + assertThat(bad.getRecords().getFirst().getErrors()).isNotEmpty(); + var good = new InsertAction().execute(new InsertInput("crm_activity").withRecord(new QRecord().withValue("subject", "Call").withValue("ownerUserId", TEST_USER_ID) + .withValue("activityTypeId", 1).withValue("outcomeId", 10))); + assertThat(good.getRecords().getFirst().getErrors()).isNullOrEmpty(); + var cleared = new UpdateAction().execute(new UpdateInput("crm_activity").withRecord(new QRecord() + .withValue("id", good.getRecords().getFirst().getValue("id")).withValue("outcomeId", null))); + assertThat(cleared.getRecords().getFirst().getErrors()).isNullOrEmpty(); + } +} -- 2.54.0 From da0ba969660a3b6178e17708d88dd9884efc7eb7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:54:55 -0500 Subject: [PATCH 5/8] feat: PII classification, optional tenancy locks, search fields and API publication Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +- docs/ddl/postgres.sql | 3 +- pom.xml | 11 ++ .../kingsrook/qbits/crm/CrmIntegrations.java | 87 ++++++++++ .../kingsrook/qbits/crm/CrmQBitConfig.java | 147 +++++++++++----- .../kingsrook/qbits/crm/CrmQBitProducer.java | 11 ++ .../qbits/crm/core/model/Company.java | 27 --- .../qbits/crm/core/model/ConsentRecord.java | 8 + .../qbits/crm/core/model/Contact.java | 27 --- .../kingsrook/qbits/crm/deals/model/Deal.java | 30 +--- .../qbits/crm/security/CrmDataPolicy.java | 137 +++++++++++++++ .../qbit-crm/crm-tenancy.changelog.sql | 50 ++++++ .../db/liquibase/qbit-crm/crm.changelog.sql | 3 +- .../qbits/crm/CrmDataPolicyTest.java | 163 ++++++++++++++++++ .../qbits/crm/CrmQBitConfigTest.java | 17 +- .../com/kingsrook/qbits/crm/CrmRdbmsTest.java | 27 +++ .../kingsrook/qbits/crm/P4TestSupport.java | 67 +++++++ 17 files changed, 682 insertions(+), 144 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/crm/CrmIntegrations.java create mode 100644 src/main/java/com/kingsrook/qbits/crm/security/CrmDataPolicy.java create mode 100644 src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql create mode 100644 src/test/java/com/kingsrook/qbits/crm/CrmDataPolicyTest.java create mode 100644 src/test/java/com/kingsrook/qbits/crm/P4TestSupport.java diff --git a/README.md b/README.md index bb4dadc..358ead9 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,14 @@ qInstance.setEnforcePermissionsInCoreActions(true); - `defaultBackendNameForTables`: required backend name. - `inferBackendNames`: defaults to true; sets snake_case SQL column names while Java/QQQ field names remain camelCase. SQL table names equal the QQQ table names. Set false only for an explicit host column mapping. - `tableMetaDataCustomizer`: optional host table customizer. -- `securityFields` and `recordSecurityLocks`: retained in P1, applied to contact, company and deal. Host-managed schema columns must match these fields. This is not yet tenant isolation across every child table or workflow. +- `withTenantSecurityKeyType("tenant")`: enables nullable `tenantKey` fields and record locks. It defaults to null (off). `withTenantAllAccessKeyName("allTenants")` names the host's all-access key. Existing host key types are preserved. +- Tenant-enabled hosts separately include `src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql` **after** the base changelog and explicitly select the `crm-tenancy` context. Single-tenant hosts omit that file. Child records use parent join locks; polymorphic external mappings and internal job checkpoints carry direct tenant keys. Hosts must supply tenant keys on new direct-key records. +- `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields. +- `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads. +- `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables. +- `CrmHealthChecks.indicators(monitoringSession)`: register the returned indicators with the host health middleware. Include optional `qqq-middleware-health`; the supplied session determines tenant scope and is restored on worker threads. `run()` uses the caller's context. No check adds permissions or tenant keys. + +The shipped help bundle and health adapter are temporary core integration adapters; their removal target is RC.9. See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits. | Scope | Permissions | |---|---| @@ -72,7 +79,7 @@ Permissions are independent grants. Every CRM-produced table, process and widget ## Tables -All 33 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata. +All 34 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata. ### Main tables (6) diff --git a/docs/ddl/postgres.sql b/docs/ddl/postgres.sql index 1a9ba4b..9aec5e2 100644 --- a/docs/ddl/postgres.sql +++ b/docs/ddl/postgres.sql @@ -5,7 +5,8 @@ -- Unique constraints match QQQ metadata. Relationship columns are indexed; -- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns -- validation and deletion policy, including polymorphic and optional references). --- Optional host-configured securityFields require matching host-managed columns. +-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy. +-- With tenancy off, do not include it; this base schema has no tenant_key columns. CREATE TABLE IF NOT EXISTS crm_activity ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, diff --git a/pom.xml b/pom.xml index 8be0196..7aeb01b 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,17 @@ + + com.kingsrook.qqq + qqq-middleware-api + true + + + com.kingsrook.qqq + qqq-middleware-health + true + + com.kingsrook.qbits qbit-email diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmIntegrations.java b/src/main/java/com/kingsrook/qbits/crm/CrmIntegrations.java new file mode 100644 index 0000000..9c362a5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/CrmIntegrations.java @@ -0,0 +1,87 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.crm.security.CrmDataPolicy; +import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData; +import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Optional host API publication and the core record-search contract. + *******************************************************************************/ +public final class CrmIntegrations +{ + private static final Map> SEARCH_FIELDS = Map.of( + "crm_contact", List.of("firstName", "lastName", "email"), + "crm_company", List.of("name", "domain"), "crm_deal", List.of("name"), + "crm_activity", List.of("subject"), "crm_form_submission", List.of("email"), "crm_email_list", List.of("name")); + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CrmIntegrations() + { + } + + + /******************************************************************************* + ** Enable publication only when the host supplies both API settings. + *******************************************************************************/ + public static void apply(MetaDataProducerMultiOutput output, CrmQBitConfig config) throws QException + { + if(config.getEsbDestinationName() != null) + { + throw (new QException("CRM ESB publication requires classified-value exclusion, which QQQ 4.1.0-RC.6 does not provide.")); + } + boolean api = config.getApiName() != null && config.getApiVersion() != null; + if(api) + { + try + { + Class.forName("com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer"); + } + catch(ClassNotFoundException | LinkageError e) + { + throw (new QException("CRM API publication requires qqq-middleware-api on the host classpath.", e)); + } + } + for(QTableMetaData table : output.getEach(QTableMetaData.class)) + { + if(SEARCH_FIELDS.containsKey(table.getName())) + { + table.withSearchFields(SEARCH_FIELDS.get(table.getName())); + } + if(api && CrmDataPolicy.isPublishedTable(table.getName())) + { + ApiTableMetaDataContainer.ofOrWithNew(table).withApiTableMetaData(config.getApiName(), + new ApiTableMetaData().withInitialVersion(config.getApiVersion()).withIsExcluded(false)); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java index 40ace60..adb75e4 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java @@ -27,10 +27,8 @@ package com.kingsrook.qbits.crm; import java.util.List; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; -import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -43,6 +41,12 @@ public class CrmQBitConfig implements QBitConfig { public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig(); + private String tenantSecurityKeyType; + private String tenantAllAccessKeyName; + private String apiName; + private String apiVersion; + private String esbDestinationName; + private String schedulerName; private Boolean schedulesEnabled = true; @@ -56,8 +60,6 @@ public class CrmQBitConfig implements QBitConfig private Boolean inferBackendNames = true; private Boolean appEnabled = true; private MetaDataCustomizerInterface tableMetaDataCustomizer; - private List securityFields; - private List recordSecurityLocks; @@ -137,48 +139,6 @@ public class CrmQBitConfig implements QBitConfig - /******************************************************************************* - ** Getter for securityFields - *******************************************************************************/ - public List getSecurityFields() - { - return (this.securityFields); - } - - - - /******************************************************************************* - ** Fluent setter for securityFields - *******************************************************************************/ - public CrmQBitConfig withSecurityFields(List securityFields) - { - this.securityFields = securityFields; - return (this); - } - - - - /******************************************************************************* - ** Getter for recordSecurityLocks - *******************************************************************************/ - public List getRecordSecurityLocks() - { - return (this.recordSecurityLocks); - } - - - - /******************************************************************************* - ** Fluent setter for recordSecurityLocks - *******************************************************************************/ - public CrmQBitConfig withRecordSecurityLocks(List recordSecurityLocks) - { - this.recordSecurityLocks = recordSecurityLocks; - return (this); - } - - - /******************************************************************************* ** Whether to infer snake_case database column names. *******************************************************************************/ @@ -308,4 +268,99 @@ public class CrmQBitConfig implements QBitConfig return (this); } + /******************************************************************************* + ** Getter for tenantSecurityKeyType. + *******************************************************************************/ + public String getTenantSecurityKeyType() + { + return (tenantSecurityKeyType); + } + + + /******************************************************************************* + ** Configure tenantSecurityKeyType; null disables the optional integration. + *******************************************************************************/ + public CrmQBitConfig withTenantSecurityKeyType(String tenantSecurityKeyType) + { + this.tenantSecurityKeyType = tenantSecurityKeyType; + return (this); + } + + + /******************************************************************************* + ** Getter for tenantAllAccessKeyName. + *******************************************************************************/ + public String getTenantAllAccessKeyName() + { + return (tenantAllAccessKeyName); + } + + + /******************************************************************************* + ** Configure tenantAllAccessKeyName; null disables the optional integration. + *******************************************************************************/ + public CrmQBitConfig withTenantAllAccessKeyName(String tenantAllAccessKeyName) + { + this.tenantAllAccessKeyName = tenantAllAccessKeyName; + return (this); + } + + + /******************************************************************************* + ** Getter for apiName. + *******************************************************************************/ + public String getApiName() + { + return (apiName); + } + + + /******************************************************************************* + ** Configure apiName; null disables the optional integration. + *******************************************************************************/ + public CrmQBitConfig withApiName(String apiName) + { + this.apiName = apiName; + return (this); + } + + + /******************************************************************************* + ** Getter for apiVersion. + *******************************************************************************/ + public String getApiVersion() + { + return (apiVersion); + } + + + /******************************************************************************* + ** Configure apiVersion; null disables the optional integration. + *******************************************************************************/ + public CrmQBitConfig withApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + return (this); + } + + + /******************************************************************************* + ** Getter for esbDestinationName. + *******************************************************************************/ + public String getEsbDestinationName() + { + return (esbDestinationName); + } + + + /******************************************************************************* + ** Configure esbDestinationName; null disables the optional integration. + *******************************************************************************/ + public CrmQBitConfig withEsbDestinationName(String esbDestinationName) + { + this.esbDestinationName = esbDestinationName; + return (this); + } + + } diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index f35d05f..1eb2e05 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -41,8 +41,11 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealProduct; import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity; +import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn; import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer; +import com.kingsrook.qbits.crm.security.CrmDataPolicy; import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qbits.crm.security.CrmRelationshipValidator; import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData; import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; @@ -113,6 +116,8 @@ public class CrmQBitProducer implements QBitMetaDataProducer { join.setName(snakeCase(join.getName())); } + CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig); + CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig); for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class)) { widget.setName(snakeCase(widget.getName())); @@ -160,6 +165,11 @@ public class CrmQBitProducer implements QBitMetaDataProducer for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class)) { String tableName = table.getName(); + if(tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME)) + { + addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CrmRelationshipValidator.class)); + addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CrmRelationshipValidator.class)); + } table.setPermissionRules(CrmPermissions.tableRules(tableName)); table.setSourceQBitName(getQBitMetaData().getName()); if(table.getSections() != null) @@ -359,6 +369,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer } qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class)); registerHooks(qInstance); + CrmHelpBundleStandIn.apply(output); return (output); } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java b/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java index 0d9a3e1..c576482 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java @@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.model; import java.math.BigDecimal; import java.time.Instant; import java.util.List; -import com.kingsrook.qbits.crm.CrmQBitConfig; import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType; import com.kingsrook.qbits.crm.deals.model.Deal; @@ -46,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; -import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; -import com.kingsrook.qqq.backend.core.utils.collections.MutableList; /******************************************************************************* @@ -108,30 +104,7 @@ public class Company extends QRecordEntity .withSection(SectionFactory.defaultT2("description")) .withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate")); - /////////////////////////////////////////////////////////////// - // inject security fields and locks from CrmQBitConfig // - /////////////////////////////////////////////////////////////// - CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig(); - if(config != null) - { - if(config.getSecurityFields() != null) - { - t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames())); - for(QFieldMetaData fieldMetaData : config.getSecurityFields()) - { - table.addField(fieldMetaData.clone()); - t1Section.getFieldNames().add(fieldMetaData.getName()); - } - } - if(config.getRecordSecurityLocks() != null) - { - for(RecordSecurityLock lock : config.getRecordSecurityLocks()) - { - table.withRecordSecurityLock(lock); - } - } - } return (table); } 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 7b1138a..f83529a 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 @@ -28,9 +28,11 @@ package com.kingsrook.qbits.crm.core.model; import java.time.Instant; import java.util.List; +import java.util.Set; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis; +import com.kingsrook.qbits.crm.privacy.CrmPersonalData.ErasureFlag; import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; @@ -111,6 +113,12 @@ public class ConsentRecord extends QRecordEntity @Override public List apply(List records) throws QException { + if(updateInput != null && updateInput.hasFlag(ErasureFlag.REDACT_CONSENT) + && records.stream().allMatch(row -> Set.of("id", "consentText").containsAll(row.getValues().keySet()) + && "erased".equals(row.getValueString("consentText")))) + { + return (records); + } throw new QUserFacingException("Consent records cannot be modified or deleted"); } } 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 1385b5d..8cf356f 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 @@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.model; import java.time.Instant; import java.util.List; -import com.kingsrook.qbits.crm.CrmQBitConfig; import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.email.model.EmailMessage; @@ -46,13 +45,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; -import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; -import com.kingsrook.qqq.backend.core.utils.collections.MutableList; /******************************************************************************* @@ -126,30 +122,7 @@ public class Contact extends QRecordEntity .withSection(SectionFactory.defaultT2("description")) .withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate")); - /////////////////////////////////////////////////////////////// - // inject security fields and locks from CrmQBitConfig // - /////////////////////////////////////////////////////////////// - CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig(); - if(config != null) - { - if(config.getSecurityFields() != null) - { - t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames())); - for(QFieldMetaData fieldMetaData : config.getSecurityFields()) - { - table.addField(fieldMetaData.clone()); - t1Section.getFieldNames().add(fieldMetaData.getName()); - } - } - if(config.getRecordSecurityLocks() != null) - { - for(RecordSecurityLock lock : config.getRecordSecurityLocks()) - { - table.withRecordSecurityLock(lock); - } - } - } return (table); } diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java b/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java index 29c092a..8f99aad 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java @@ -30,7 +30,6 @@ import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; import java.util.List; -import com.kingsrook.qbits.crm.CrmQBitConfig; import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.core.model.Company; import com.kingsrook.qbits.crm.core.model.Contact; @@ -52,13 +51,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.Child import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; -import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; -import com.kingsrook.qqq.backend.core.utils.collections.MutableList; /******************************************************************************* @@ -125,30 +121,7 @@ public class Deal extends QRecordEntity .withSection(SectionFactory.defaultT2("description")) .withSection(SectionFactory.defaultT3("stageEnteredDate", "lastActivityDate", "createDate", "modifyDate")); - /////////////////////////////////////////////////////////////// - // inject security fields and locks from CrmQBitConfig // - /////////////////////////////////////////////////////////////// - CrmQBitConfig config = (CrmQBitConfig) QBitProductionContext.peekQBitConfig(); - if(config != null) - { - if(config.getSecurityFields() != null) - { - t1Section.setFieldNames(new MutableList<>(t1Section.getFieldNames())); - for(QFieldMetaData fieldMetaData : config.getSecurityFields()) - { - table.addField(fieldMetaData.clone()); - t1Section.getFieldNames().add(fieldMetaData.getName()); - } - } - if(config.getRecordSecurityLocks() != null) - { - for(RecordSecurityLock lock : config.getRecordSecurityLocks()) - { - table.withRecordSecurityLock(lock); - } - } - } /////////////////////////////////////////////////////////// // Only offer stages belonging to the selected pipeline. // @@ -156,6 +129,9 @@ public class Deal extends QRecordEntity table.getField("pipelineStageId").withPossibleValueSourceFilter(new QQueryFilter( new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}"))); + table.getField("primaryContactId").withPossibleValueSourceFilter(new QQueryFilter( + new QFilterCriteria("companyId", QCriteriaOperator.EQUALS, "${input.companyId}"))); + return (table); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/security/CrmDataPolicy.java b/src/main/java/com/kingsrook/qbits/crm/security/CrmDataPolicy.java new file mode 100644 index 0000000..10fdb2e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/security/CrmDataPolicy.java @@ -0,0 +1,137 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm.security; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.crm.CrmAppMetaData; +import com.kingsrook.qbits.crm.CrmQBitConfig; +import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; + + +/******************************************************************************* + ** CRM field classification and optional tenant record locks. + *******************************************************************************/ +public final class CrmDataPolicy +{ + private static final Map> PERSONAL_FIELDS = Map.of( + "crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1", + "addressLine2", "city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"), + "crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"), + "crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"), + "crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"), + "crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"), + "crm_consent_record", List.of("consentText"), + "crm_activity", List.of("description")); + + private static final Map PARENTS = Map.ofEntries( + Map.entry("crm_pipeline_stage", "crm_pipeline"), + Map.entry("crm_assignment_rule_member", "crm_assignment_rule"), + Map.entry("crm_consent_record", "crm_contact"), + Map.entry("crm_email_list_member", "crm_contact"), + Map.entry("crm_email_message", "crm_contact"), + Map.entry("crm_sequence_enrollment", "crm_contact"), + Map.entry("crm_sequence_step", "crm_email_sequence"), + Map.entry("crm_activity_participant", "crm_activity"), + Map.entry("crm_deal_contact", "crm_deal"), + Map.entry("crm_deal_product", "crm_deal"), + Map.entry("crm_deal_stage_history", "crm_deal")); + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CrmDataPolicy() + { + } + + + /******************************************************************************* + ** Apply after joins have their final names, before backend name inference. + *******************************************************************************/ + public static void apply(MetaDataProducerMultiOutput output, QInstance instance, CrmQBitConfig config) + { + String keyType = config.getTenantSecurityKeyType(); + if(keyType != null && instance.getSecurityKeyType(keyType) == null) + { + output.add(new QSecurityKeyType().withName(keyType).withAllAccessKeyName(config.getTenantAllAccessKeyName())); + } + for(QTableMetaData table : output.getEach(QTableMetaData.class)) + { + for(String name : PERSONAL_FIELDS.getOrDefault(table.getName(), List.of())) + { + QFieldMetaData field = table.getField(name); + if(field == null) + { + throw (new IllegalStateException("Unknown CRM PII field: " + table.getName() + "." + name)); + } + AiFieldMetaData ai = AiFieldMetaData.of(field); + field.withSupplementalMetaData((ai == null ? new AiFieldMetaData() : ai).withClassification(DataClassification.PII)); + } + if(keyType == null) + { + continue; + } + RecordSecurityLock lock = new RecordSecurityLock().withSecurityKeyType(keyType); + String parent = PARENTS.get(table.getName()); + if(parent == null) + { + // Polymorphic external mappings and internal job checkpoints need a direct key too. + table.withField(new QFieldMetaData("tenantKey", QFieldType.STRING).withMaxLength(250)); + if(table.getSections() != null && !table.getSections().isEmpty()) + { + table.withSection(new QFieldSection("crm_tenancy", "Tenancy", new QIcon("lock"), Tier.T3, List.of("tenantKey"))); + } + lock.withFieldName("tenantKey"); + } + else + { + QJoinMetaData join = output.getEach(QJoinMetaData.class).stream() + .filter(candidate -> parent.equals(candidate.getLeftTable()) && table.getName().equals(candidate.getRightTable())) + .findFirst().orElseThrow(() -> new IllegalStateException("Missing tenant parent join for " + table.getName())); + lock.withFieldName(parent + ".tenantKey").withJoinNameChain(List.of(join.getName())); + } + table.withRecordSecurityLock(lock); + } + } + + + /******************************************************************************* + ** Main and settings tables exposed through the host API. + *******************************************************************************/ + public static boolean isPublishedTable(String name) + { + return (CrmAppMetaData.MAIN_TABLES.contains(name) || CrmAppMetaData.SETTINGS_TABLES.contains(name)); + } +} diff --git a/src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql b/src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql new file mode 100644 index 0000000..5fc2027 --- /dev/null +++ b/src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql @@ -0,0 +1,50 @@ +--liquibase formatted sql +-- Optional: include after crm.changelog.sql ONLY for tenant-enabled installations. +-- Run with context crm-tenancy. Omit this file entirely when tenancy is off. +--changeset qbit-crm:020-crm-tenancy context:@crm-tenancy +ALTER TABLE crm_contact ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_contact_tenant ON crm_contact (tenant_key); +ALTER TABLE crm_company ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_company_tenant ON crm_company (tenant_key); +ALTER TABLE crm_deal ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_deal_tenant ON crm_deal (tenant_key); +ALTER TABLE crm_activity ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_activity_tenant ON crm_activity (tenant_key); +ALTER TABLE crm_form_submission ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_form_submission_tenant ON crm_form_submission (tenant_key); +ALTER TABLE crm_email_list ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_email_list_tenant ON crm_email_list (tenant_key); +ALTER TABLE crm_pipeline ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_pipeline_tenant ON crm_pipeline (tenant_key); +ALTER TABLE crm_product ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_product_tenant ON crm_product (tenant_key); +ALTER TABLE crm_email_template ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_email_template_tenant ON crm_email_template (tenant_key); +ALTER TABLE crm_email_sequence ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_email_sequence_tenant ON crm_email_sequence (tenant_key); +ALTER TABLE crm_external_mapping ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_external_mapping_tenant ON crm_external_mapping (tenant_key); +ALTER TABLE crm_lead_score_rule ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_lead_score_rule_tenant ON crm_lead_score_rule (tenant_key); +ALTER TABLE crm_assignment_rule ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_assignment_rule_tenant ON crm_assignment_rule (tenant_key); +ALTER TABLE crm_sales_goal ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_sales_goal_tenant ON crm_sales_goal (tenant_key); +ALTER TABLE crm_currency ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_currency_tenant ON crm_currency (tenant_key); +ALTER TABLE crm_lifecycle_stage ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_lifecycle_stage_tenant ON crm_lifecycle_stage (tenant_key); +ALTER TABLE crm_lead_source ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_lead_source_tenant ON crm_lead_source (tenant_key); +ALTER TABLE crm_industry ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_industry_tenant ON crm_industry (tenant_key); +ALTER TABLE crm_activity_type ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_activity_type_tenant ON crm_activity_type (tenant_key); +ALTER TABLE crm_activity_outcome ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_activity_outcome_tenant ON crm_activity_outcome (tenant_key); +ALTER TABLE crm_contact_role ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_contact_role_tenant ON crm_contact_role (tenant_key); +ALTER TABLE crm_win_loss_reason ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_win_loss_reason_tenant ON crm_win_loss_reason (tenant_key); +ALTER TABLE crm_job_state ADD COLUMN tenant_key VARCHAR(250); +CREATE INDEX idx_crm_job_state_tenant ON crm_job_state (tenant_key); 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 40ac37b..180cdff 100644 --- a/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql @@ -6,7 +6,8 @@ -- Unique constraints match QQQ metadata. Relationship columns are indexed; -- foreign-key constraints are deliberately not imposed (the QQQ/host layer owns -- validation and deletion policy, including polymorphic and optional references). --- Optional host-configured securityFields require matching host-managed columns. +-- Optional tenancy: separately include crm-tenancy.changelog.sql with context crm-tenancy. +-- With tenancy off, do not include it; this base schema has no tenant_key columns. --changeset qbit-crm:020-crm_activity CREATE TABLE IF NOT EXISTS crm_activity ( diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmDataPolicyTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmDataPolicyTest.java new file mode 100644 index 0000000..0645ade --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmDataPolicyTest.java @@ -0,0 +1,163 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.regex.Pattern; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** PII inventory and real tenant isolation through core QueryAction. + *******************************************************************************/ +class CrmDataPolicyTest extends BaseTest +{ + /******************************************************************************* + ** Complete pii inventory. + *******************************************************************************/ + @Test + void completePiiInventory() + { + Map> expected = Map.of( + "crm_contact", List.of("firstName", "lastName", "email", "secondaryEmail", "phone", "mobilePhone", "addressLine1", "addressLine2", + "city", "state", "postalCode", "country", "linkedinUrl", "timezone", "jobTitle", "department", "website", "description"), + "crm_company", List.of("phone", "addressLine1", "addressLine2", "city", "state", "postalCode", "country"), + "crm_form_submission", List.of("firstName", "lastName", "email", "phone", "message", "clientHash", "visitorKey", "consentText", "company"), + "crm_email_list_member", List.of("email", "firstName", "consentText", "confirmationTokenHash"), + "crm_email_message", List.of("fromAddress", "fromName", "toAddresses", "ccAddresses", "bccAddresses", "subject"), + "crm_consent_record", List.of("consentText"), "crm_activity", List.of("description")); + TreeSet actual = new TreeSet<>(); + QContext.getQInstance().getTables().values().forEach(table -> table.getFields().values().forEach(field -> + { + if(AiFieldMetaData.of(field) != null && AiFieldMetaData.of(field).getClassification() == DataClassification.PII) + { + actual.add(table.getName() + "." + field.getName()); + } + })); + TreeSet inventory = new TreeSet<>(); + expected.forEach((table, fields) -> fields.forEach(field -> inventory.add(table + "." + field))); + assertThat(actual).containsExactlyElementsOf(inventory); + } + + + /******************************************************************************* + ** Personal field names cannot lose classification. + *******************************************************************************/ + @Test + void personalFieldNamesCannotLoseClassification() + { + Pattern pattern = Pattern.compile("email|phone|firstName|lastName|address|postal|linkedin", Pattern.CASE_INSENSITIVE); + QContext.getQInstance().getTables().values().forEach(table -> table.getFields().values().forEach(field -> + { + // Foreign-key ids, preference booleans and transport message ids are not personal text. + if(field.getType() == QFieldType.STRING && pattern.matcher(field.getName()).find() && !field.getName().equals("emailMessageId")) + { + assertThat(AiFieldMetaData.of(field)).as(table.getName() + "." + field.getName()).isNotNull(); + assertThat(AiFieldMetaData.of(field).getClassification()).isEqualTo(DataClassification.PII); + } + })); + } + + + /******************************************************************************* + ** Tenancy off has no fields or locks. + *******************************************************************************/ + @Test + void tenancyOffHasNoFieldsOrLocks() + { + assertThat(new CrmQBitConfig().getTenantSecurityKeyType()).isNull(); + QContext.getQInstance().getTables().values().forEach(table -> + { + assertThat(table.getFields().get("tenantKey")).isNull(); + assertThat(table.getRecordSecurityLocks()).isNullOrEmpty(); + }); + } + + + /******************************************************************************* + ** Tenant asees only its contacts deals and joined products. + *******************************************************************************/ + @Test + void tenantASeesOnlyItsContactsDealsAndJoinedProducts() throws Exception + { + QInstance instance = P4TestSupport.host(); + new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME) + .withTenantSecurityKeyType("tenant").withTenantAllAccessKeyName("allTenants")).produce(instance).addSelfToInstance(instance); + new QInstanceValidator().validate(instance); + QContext.init(instance, new QSession().withPermissions(CrmPermissions.ALL).withSecurityKeyValue("tenant", "A")); + for(String table : List.of("crm_contact", "crm_deal")) + { + P4TestSupport.seed(table, new QRecord().withValue("id", 1).withValue("tenantKey", "A"), + new QRecord().withValue("id", 2).withValue("tenantKey", "B"), new QRecord().withValue("id", 3)); + } + P4TestSupport.seed("crm_deal_product", new QRecord().withValue("id", 11).withValue("dealId", 1), + new QRecord().withValue("id", 22).withValue("dealId", 2)); + assertThat(ids("crm_contact")).containsExactly(1); + assertThat(ids("crm_deal")).containsExactly(1); + assertThat(ids("crm_deal_product")).containsExactly(11); + QContext.getQSession().withSecurityKeyValue("allTenants", true); + assertThat(ids("crm_contact")).contains(1, 2); + assertThat(ids("crm_deal")).contains(1, 2); + assertThat(ids("crm_deal_product")).containsExactlyInAnyOrder(11, 22); + instance.getTables().values().forEach(table -> assertThat(table.getRecordSecurityLocks()).as(table.getName()).isNotEmpty()); + assertThat(instance.getTable("crm_deal_product").getFields().get("tenantKey")).isNull(); + } + + + /******************************************************************************* + ** Preserves host key type. + *******************************************************************************/ + @Test + void preservesHostKeyType() throws Exception + { + QInstance instance = P4TestSupport.host(); + QSecurityKeyType key = new QSecurityKeyType().withName("tenant").withAllAccessKeyName("hostAll"); + instance.addSecurityKeyType(key); + new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME) + .withTenantSecurityKeyType("tenant").withTenantAllAccessKeyName("ignored")).produce(instance).addSelfToInstance(instance); + assertThat(instance.getSecurityKeyType("tenant")).isSameAs(key); + } + + + /******************************************************************************* + ** Read through real record locks. + *******************************************************************************/ + private List ids(String table) throws Exception + { + return (new QueryAction().execute(new QueryInput(table)).getRecords().stream().map(row -> row.getValueInteger("id")).toList()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java index b816516..4a3c211 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java @@ -29,10 +29,7 @@ package com.kingsrook.qbits.crm; import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -50,26 +47,20 @@ class CrmQBitConfigTest @Test void testFluentSettersAndGetters() { - List securityFields = List.of( - new QFieldMetaData("tenantId", QFieldType.INTEGER)); - - List locks = List.of( - new RecordSecurityLock().withFieldName("tenantId")); - MetaDataCustomizerInterface customizer = (qInstance, table) -> table; CrmQBitConfig config = new CrmQBitConfig() .withDefaultBackendNameForTables("myBackend") .withInferBackendNames(false) .withTableMetaDataCustomizer(customizer) - .withSecurityFields(securityFields) - .withRecordSecurityLocks(locks); + .withTenantSecurityKeyType("tenant") + .withTenantAllAccessKeyName("allTenants"); assertThat(config.getDefaultBackendNameForTables()).isEqualTo("myBackend"); assertThat(config.getInferBackendNames()).isFalse(); assertThat(config.getTableMetaDataCustomizer()).isSameAs(customizer); - assertThat(config.getSecurityFields()).isSameAs(securityFields); - assertThat(config.getRecordSecurityLocks()).isSameAs(locks); + assertThat(config.getTenantSecurityKeyType()).isEqualTo("tenant"); + assertThat(config.getTenantAllAccessKeyName()).isEqualTo("allTenants"); } diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java index 4daa375..3f23437 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java @@ -370,4 +370,31 @@ class CrmRdbmsTest assertThat(leaders.getRows().getFirst().get("first").toString()).endsWith(">Ada"); } + + /******************************************************************************* + ** The base schema stays unchanged; only the explicit tenant migration adds keys. + *******************************************************************************/ + @Test + void testOptionalTenantSchemaMatchesMetadata() throws Exception + { + try(var columns = connection.getMetaData().getColumns(null, null, "crm_contact", "tenant_key")) + { + assertThat(columns.next()).isFalse(); + } + executeSql(Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql"))); + QInstance tenantInstance = new QInstance(); + tenantInstance.addBackend(qInstance.getBackend("rdbms")); + tenantInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables("rdbms") + .withTenantSecurityKeyType("tenant")).produce(tenantInstance).addSelfToInstance(tenantInstance); + new QInstanceValidator().validate(tenantInstance); + for(var table : tenantInstance.getTables().values()) + { + try(var columns = connection.getMetaData().getColumns(null, null, table.getName(), "tenant_key")) + { + assertThat(columns.next()).as(table.getName()).isEqualTo(table.getFields().containsKey("tenantKey")); + } + } + } + } diff --git a/src/test/java/com/kingsrook/qbits/crm/P4TestSupport.java b/src/test/java/com/kingsrook/qbits/crm/P4TestSupport.java new file mode 100644 index 0000000..a8d475c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/P4TestSupport.java @@ -0,0 +1,67 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.crm; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; + + +/******************************************************************************* + ** Isolated host instances and fixture inserts, without bypassing actions under test. + *******************************************************************************/ +final class P4TestSupport +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private P4TestSupport() + { + } + + + /******************************************************************************* + ** Build a host without CRM, ready for configuration-specific production. + *******************************************************************************/ + static QInstance host() + { + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + return (instance); + } + + + /******************************************************************************* + ** Fixture storage intentionally avoids business side effects. + *******************************************************************************/ + static void seed(String table, QRecord... rows) throws QException + { + MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecords(java.util.List.of(rows)), false); + } +} -- 2.54.0 From 40a93c3286baacd74f761e9c06d8376f946b16f6 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:57:35 -0500 Subject: [PATCH 6/8] fix(app): produce a plain QAppMetaData so hosts can attach it as a child app Co-Authored-By: Claude Opus 5.5 --- .../kingsrook/qbits/crm/CrmAppMetaData.java | 37 +------------------ .../qbits/crm/CrmAppMetaDataTest.java | 2 - 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java b/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java index d1c0523..d6b0227 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java @@ -30,17 +30,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* ** The CRM home and the two navigation sections. Child tables stay on records. *******************************************************************************/ -public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAware +public final class CrmAppMetaData { - private String sourceQBitName; - public static final List MAIN_TABLES = List.of("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list"); public static final List SETTINGS_TABLES = List.of("crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence", "crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_currency", "crm_lifecycle_stage", "crm_lead_source", @@ -70,7 +67,7 @@ public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAwar *******************************************************************************/ public static QAppMetaData build(MetaDataProducerMultiOutput output, String source) { - QAppMetaData app = new CrmAppMetaData().withSourceQBitName(source).withName("crm").withLabel("CRM").withIcon(new QIcon("contacts")) + QAppMetaData app = new QAppMetaData().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts")) .withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE)) .withWidgets(new ArrayList<>(CrmHomeWidgets.HOME)) .withSection(mainSection()) @@ -84,34 +81,4 @@ public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAwar } return (app); } - - /******************************************************************************* - ** QBit provenance, until core apps implement SourceQBitAware. - *******************************************************************************/ - @Override - public String getSourceQBitName() - { - return (sourceQBitName); - } - - - /******************************************************************************* - ** QBit provenance, until core apps implement SourceQBitAware. - *******************************************************************************/ - @Override - public void setSourceQBitName(String sourceQBitName) - { - this.sourceQBitName = sourceQBitName; - } - - - /******************************************************************************* - ** QBit provenance, until core apps implement SourceQBitAware. - *******************************************************************************/ - @Override - public CrmAppMetaData withSourceQBitName(String sourceQBitName) - { - setSourceQBitName(sourceQBitName); - return (this); - } } diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java index 3ef97b2..b35e4a8 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java @@ -35,7 +35,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -70,7 +69,6 @@ class CrmAppMetaDataTest extends BaseTest assertThat(app.getWidgets()).containsExactlyElementsOf(CrmHomeWidgets.HOME); assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("crm"); assertThat(app.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION); - assertThat(((SourceQBitAware) app).getSourceQBitName()).isEqualTo("com.kingsrook.qbits:crm"); assertThat(CrmQBitProducer.produceAppSection().getTables()).containsExactlyElementsOf(CrmAppMetaData.MAIN_TABLES); assertThat(QContext.getQInstance().getTable("crm_form_submission").getLabel()).isEqualTo("Web Leads"); assertThat(QContext.getQInstance().getTable("crm_email_list").getLabel()).isEqualTo("Lists"); -- 2.54.0 From 212c7aaa7748ee0d1473fc6ffff776a503947c91 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:57:35 -0500 Subject: [PATCH 7/8] fix(home): let Next size home table columns (no fixed widths) Co-Authored-By: Claude Opus 5.5 --- .../java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java index 95c7d71..9f5a098 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java @@ -489,8 +489,8 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer *******************************************************************************/ static TableData table(List> rows, String first, String second, String third) { - return (new TableData(null, List.of(new TableData.Column("html", first, "first", "2fr", "left"), - new TableData.Column("html", second, "second", "1fr", "left"), new TableData.Column("html", third, "third", "1fr", "left")), rows) + return (new TableData(null, List.of(new TableData.Column("html", first, "first", null, "left"), + new TableData.Column("html", second, "second", null, "left"), new TableData.Column("html", third, "third", null, "left")), rows) .withRowsPerPage(10).withHidePaginationDropdown(true)); } } -- 2.54.0 From 1f994e127b79aea4b1f2c2e7bcb384e48872d598 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 14:57:36 -0500 Subject: [PATCH 8/8] fix(schema): 320-char emails, 500-char UTM fields; cap lead source names from UTM at 100 Co-Authored-By: Claude Opus 5.5 --- docs/ddl/postgres.sql | 38 +++++++++---------- .../kingsrook/qbits/crm/api/CrmIntakeApi.java | 3 +- .../qbits/crm/core/model/Contact.java | 24 ++++++------ .../qbits/crm/core/model/EmailListMember.java | 2 +- .../qbits/crm/core/model/FormSubmission.java | 12 +++--- .../db/liquibase/qbit-crm/crm.changelog.sql | 38 +++++++++---------- 6 files changed, 59 insertions(+), 58 deletions(-) diff --git a/docs/ddl/postgres.sql b/docs/ddl/postgres.sql index 9aec5e2..1457ac1 100644 --- a/docs/ddl/postgres.sql +++ b/docs/ddl/postgres.sql @@ -162,27 +162,27 @@ CREATE TABLE IF NOT EXISTS crm_consent_record ( CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_record (contact_id); CREATE TABLE IF NOT EXISTS crm_contact ( - first_touch_utm_source VARCHAR(250), - first_touch_utm_medium VARCHAR(250), - first_touch_utm_campaign VARCHAR(250), - first_touch_utm_content VARCHAR(250), - first_touch_utm_term VARCHAR(250), + first_touch_utm_source VARCHAR(500), + first_touch_utm_medium VARCHAR(500), + first_touch_utm_campaign VARCHAR(500), + first_touch_utm_content VARCHAR(500), + first_touch_utm_term VARCHAR(500), first_touch_landing_page VARCHAR(1000), first_touch_referrer VARCHAR(1000), first_touch_date TIMESTAMP WITHOUT TIME ZONE, - last_touch_utm_source VARCHAR(250), - last_touch_utm_medium VARCHAR(250), - last_touch_utm_campaign VARCHAR(250), - last_touch_utm_content VARCHAR(250), - last_touch_utm_term VARCHAR(250), + last_touch_utm_source VARCHAR(500), + last_touch_utm_medium VARCHAR(500), + last_touch_utm_campaign VARCHAR(500), + last_touch_utm_content VARCHAR(500), + last_touch_utm_term VARCHAR(500), last_touch_landing_page VARCHAR(1000), last_touch_referrer VARCHAR(1000), last_touch_date TIMESTAMP WITHOUT TIME ZONE, id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, - email VARCHAR(250), - secondary_email VARCHAR(250), + email VARCHAR(320), + secondary_email VARCHAR(320), phone VARCHAR(50), mobile_phone VARCHAR(50), job_title VARCHAR(150), @@ -347,7 +347,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, email_list_id INTEGER NOT NULL, contact_id INTEGER, - email VARCHAR(250) NOT NULL, + email VARCHAR(320) NOT NULL, first_name VARCHAR(100), status INTEGER DEFAULT 1 NOT NULL, subscribed_date TIMESTAMP WITHOUT TIME ZONE, @@ -461,18 +461,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission ( client_hash VARCHAR(64), id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, form_type INTEGER NOT NULL, - email VARCHAR(250) NOT NULL, + email VARCHAR(320) NOT NULL, first_name VARCHAR(100), last_name VARCHAR(100), company VARCHAR(250), phone VARCHAR(50), message TEXT, source_url VARCHAR(1000), - utm_source VARCHAR(250), - utm_medium VARCHAR(250), - utm_campaign VARCHAR(250), - utm_term VARCHAR(250), - utm_content VARCHAR(250), + utm_source VARCHAR(500), + utm_medium VARCHAR(500), + utm_campaign VARCHAR(500), + utm_term VARCHAR(500), + utm_content VARCHAR(500), referrer VARCHAR(1000), contact_id INTEGER, conversion_status INTEGER DEFAULT 1 NOT NULL, diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java index 51300fd..b724421 100644 --- a/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java @@ -288,7 +288,8 @@ public class CrmIntakeApi QRecord contact; if(contacts.isEmpty()) { - String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource(); + String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource().strip(); + source = source.length() > 100 ? source.substring(0, 100) : source; List 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"); 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 8cf356f..064fe39 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 @@ -130,19 +130,19 @@ public class Contact extends QRecordEntity - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String firstTouchUtmSource; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String firstTouchUtmMedium; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String firstTouchUtmCampaign; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String firstTouchUtmContent; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String firstTouchUtmTerm; @QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR) @@ -154,19 +154,19 @@ public class Contact extends QRecordEntity @QField() private Instant firstTouchDate; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String lastTouchUtmSource; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String lastTouchUtmMedium; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String lastTouchUtmCampaign; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String lastTouchUtmContent; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String lastTouchUtmTerm; @QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR) @@ -187,10 +187,10 @@ public class Contact extends QRecordEntity @QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String lastName; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String email; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String secondaryEmail; @QField(maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR) 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 6877a9d..08b3139 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 @@ -123,7 +123,7 @@ public class EmailListMember extends QRecordEntity @QField(possibleValueSourceName = Contact.TABLE_NAME) private Integer contactId; - @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String email; @QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR) 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 9145bc7..ee52862 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 @@ -129,7 +129,7 @@ public class FormSubmission extends QRecordEntity @QField(isRequired = true, possibleValueSourceName = CrmFormType.NAME) private Integer formType; - @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(isRequired = true, maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String email; @QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR) @@ -150,19 +150,19 @@ public class FormSubmission extends QRecordEntity @QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String sourceUrl; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String utmSource; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String utmMedium; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String utmCampaign; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String utmTerm; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + @QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String utmContent; @QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR) 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 180cdff..4fb5250 100644 --- a/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql @@ -172,27 +172,27 @@ CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_recor --changeset qbit-crm:020-crm_contact CREATE TABLE IF NOT EXISTS crm_contact ( - first_touch_utm_source VARCHAR(250), - first_touch_utm_medium VARCHAR(250), - first_touch_utm_campaign VARCHAR(250), - first_touch_utm_content VARCHAR(250), - first_touch_utm_term VARCHAR(250), + first_touch_utm_source VARCHAR(500), + first_touch_utm_medium VARCHAR(500), + first_touch_utm_campaign VARCHAR(500), + first_touch_utm_content VARCHAR(500), + first_touch_utm_term VARCHAR(500), first_touch_landing_page VARCHAR(1000), first_touch_referrer VARCHAR(1000), first_touch_date TIMESTAMP WITHOUT TIME ZONE, - last_touch_utm_source VARCHAR(250), - last_touch_utm_medium VARCHAR(250), - last_touch_utm_campaign VARCHAR(250), - last_touch_utm_content VARCHAR(250), - last_touch_utm_term VARCHAR(250), + last_touch_utm_source VARCHAR(500), + last_touch_utm_medium VARCHAR(500), + last_touch_utm_campaign VARCHAR(500), + last_touch_utm_content VARCHAR(500), + last_touch_utm_term VARCHAR(500), last_touch_landing_page VARCHAR(1000), last_touch_referrer VARCHAR(1000), last_touch_date TIMESTAMP WITHOUT TIME ZONE, id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, - email VARCHAR(250), - secondary_email VARCHAR(250), + email VARCHAR(320), + secondary_email VARCHAR(320), phone VARCHAR(50), mobile_phone VARCHAR(50), job_title VARCHAR(150), @@ -365,7 +365,7 @@ CREATE TABLE IF NOT EXISTS crm_email_list_member ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, email_list_id INTEGER NOT NULL, contact_id INTEGER, - email VARCHAR(250) NOT NULL, + email VARCHAR(320) NOT NULL, first_name VARCHAR(100), status INTEGER DEFAULT 1 NOT NULL, subscribed_date TIMESTAMP WITHOUT TIME ZONE, @@ -484,18 +484,18 @@ CREATE TABLE IF NOT EXISTS crm_form_submission ( client_hash VARCHAR(64), id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, form_type INTEGER NOT NULL, - email VARCHAR(250) NOT NULL, + email VARCHAR(320) NOT NULL, first_name VARCHAR(100), last_name VARCHAR(100), company VARCHAR(250), phone VARCHAR(50), message TEXT, source_url VARCHAR(1000), - utm_source VARCHAR(250), - utm_medium VARCHAR(250), - utm_campaign VARCHAR(250), - utm_term VARCHAR(250), - utm_content VARCHAR(250), + utm_source VARCHAR(500), + utm_medium VARCHAR(500), + utm_campaign VARCHAR(500), + utm_term VARCHAR(500), + utm_content VARCHAR(500), referrer VARCHAR(1000), contact_id INTEGER, conversion_status INTEGER DEFAULT 1 NOT NULL, -- 2.54.0