Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2589b1d5
|
||
|
|
b4387881c8 | ||
|
|
7e6667aaff | ||
|
|
1c0fa34e7c
|
||
|
|
5dd68eb20b
|
||
|
|
d732cef626
|
No files matched your search
@@ -5,6 +5,7 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Changed
|
||||
- `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported.
|
||||
- Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up.
|
||||
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
|
||||
- Switch the optional qbit-email integration to the published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.3` (fixes #13). Preserve MARKETING messages, idempotency keys, execution links and outcome mapping; use typed readiness failures so missing providers or brand postal addresses remain send-time rejections. Upgrade `qbit-build-parent` to `2.1.1` and remove all per-repository guardrail allow-lists.
|
||||
|
||||
@@ -23,7 +23,7 @@ It lives inside the host QQQ application: its navigation, authentication, permis
|
||||
| **Execution** | Durable jobs with idempotency keys, schedules, pause, resume and cancel, and attempts with provider receipts. Failures that certainly did not send are retried with backoff. Ambiguous outcomes are reconciled and never retried blindly. Everything is re-checked at send time (approval, schedule, consent, suppression, channel rules, rate, kill switch). |
|
||||
| **Transports** | A complete controlled test transport, and email through the host's QQQ messaging provider. A channel that names a transport is not treated as a working connector unless that transport is registered and usable. |
|
||||
| **Measurement** | Delivery, awareness, engagement, activation, repeat use and qualified demand are reported separately. Each metric shows raw events and unique actors. "Not measured" is never shown as 0. Test data is kept apart. |
|
||||
| **CRM** | Uses the host CRM's own contacts, organizations, consent, suppression, lists, form submissions and activities through a narrow gateway. A qbit-crm adapter is included. Nothing is copied, and nobody becomes a lead without the campaign's explicit rule. |
|
||||
| **CRM** | Uses the host CRM's own contacts, organizations, consent, suppression, lists, form submissions and activities through a narrow gateway. A qbit-crm adapter is included (qbit-crm 0.2.0's `crm_*` tables). Nothing is copied, and nobody becomes a lead without the campaign's explicit rule. |
|
||||
| **Agent** | Plans campaigns, drafts and revises content, flags unsupported claims, stale sources and readiness gaps, and analyzes results to recommend the next experiment. It uses narrow typed tools, runs as the requesting user, sees data only as untrusted input, and sees no CRM personal data. Every run is recorded with its steps, provider, model, tokens and cost. It can create drafts and findings; it cannot approve, schedule or send. |
|
||||
| **Models** | Interchangeable providers through `qbit-assistant-provider-api`'s `AssistantModelProvider`: OpenAI-compatible endpoints through `qbit-assistant-provider-openai` (added by the host), or a host-supplied provider such as Claude. Tests use a local scripted double. With no provider configured, the agent says so and everything else works. Installing marketing does not install the assistant runtime, tables or routes. |
|
||||
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ Most rows carry a denormalized `productId`, so the optional product security loc
|
||||
- `listFormSubmissions(utmCampaign)`: ids and times only
|
||||
- `recordActivity`
|
||||
|
||||
`QbitCrmGateway` implements it over qbit-crm 0.1.0 by table name, through the standard actions, so there is no compile dependency on the CRM:
|
||||
`QbitCrmGateway` implements it over qbit-crm 0.2.0's `crm_*` tables (`crm_contact`, `crm_consent_record`, ...) by table name, through the standard actions, so there is no compile dependency on the CRM. It moves to qbit-crm's public `com.kingsrook.qbits.crm.api.CrmMarketingApi` once that is released:
|
||||
- **Email consent:** the most recent Email consent record decides (Granted and not expired). No record means no consent.
|
||||
- **Suppression:** `doNotEmail`, an unsubscribe date, or a list membership that isn't Subscribed.
|
||||
|
||||
|
||||
@@ -40,21 +40,25 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
** compile dependency on the CRM and CRM permissions and locks apply.
|
||||
**
|
||||
** Rules it applies (from qbit-crm's own model):
|
||||
** - Email consent: the contact's most recent crmConsentRecord of type Email
|
||||
** - Email consent: the contact's most recent crm_consent_record of type Email
|
||||
** decides (Granted, and not past its expiry date). No record means no
|
||||
** consent: Marketing does not send unsolicited email.
|
||||
** - Suppression: doNotEmail, an unsubscribeDate, or a list membership that
|
||||
** is not Subscribed.
|
||||
** - Activities are logged as qbit-crm "Note" activities.
|
||||
**
|
||||
** It uses qbit-crm 0.2.0's crm_* snake_case tables (no fallback to the
|
||||
** 0.1.0 camelCase names).
|
||||
*******************************************************************************/
|
||||
public class QbitCrmGateway implements MarketingCrmGateway
|
||||
{
|
||||
public static final String CONTACT_TABLE = "crmContact";
|
||||
public static final String CONSENT_TABLE = "crmConsentRecord";
|
||||
public static final String LIST_MEMBER_TABLE = "crmEmailListMember";
|
||||
public static final String FORM_SUBMISSION_TABLE = "crmFormSubmission";
|
||||
public static final String ACTIVITY_TABLE = "crmActivity";
|
||||
public static final String ACTIVITY_TYPE_TABLE = "crmActivityType";
|
||||
public static final String CONTACT_TABLE = "crm_contact";
|
||||
public static final String CONSENT_TABLE = "crm_consent_record";
|
||||
public static final String LIST_MEMBER_TABLE = "crm_email_list_member";
|
||||
public static final String FORM_SUBMISSION_TABLE = "crm_form_submission";
|
||||
public static final String ACTIVITY_TABLE = "crm_activity";
|
||||
public static final String ACTIVITY_TYPE_TABLE = "crm_activity_type";
|
||||
|
||||
|
||||
static final Integer CONSENT_TYPE_EMAIL = 1;
|
||||
static final Integer CONSENT_STATUS_GRANTED = 1;
|
||||
|
||||
@@ -19,10 +19,13 @@ package com.kingsrook.qbits.marketing.widgets;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.marketing.MarketingRuntime;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
@@ -66,6 +69,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -91,6 +95,8 @@ public final class MarketingWidgets
|
||||
public static final String CAMPAIGN_RESULTS = "marketing_campaign_results";
|
||||
public static final String CONTENT_PROPOSAL = "marketing_content_proposal";
|
||||
|
||||
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a 'UTC'", Locale.US).withZone(ZoneOffset.UTC);
|
||||
|
||||
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
|
||||
|
||||
|
||||
@@ -196,6 +202,17 @@ public final class MarketingWidgets
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A date-time for people: "Oct 14, 2026 4:53 PM UTC" (empty when null).
|
||||
*******************************************************************************/
|
||||
static String when(Object value)
|
||||
{
|
||||
Instant instant = ValueUtils.getValueAsInstant(value);
|
||||
return (instant == null ? "" : WHEN_FORMAT.format(instant));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -242,27 +259,27 @@ public final class MarketingWidgets
|
||||
for(QRecord r : MarketingStore.query(MarketingContent.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, ContentStatus.IN_REVIEW.getId())).withLimit(25)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingContent.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Waiting for review (revision " + r.getValueInteger("revisionNumber") + ")",
|
||||
"since", String.valueOf(r.getValue("modifyDate"))));
|
||||
"since", when(r.getValue("modifyDate"))));
|
||||
}
|
||||
for(QRecord r : MarketingStore.query(MarketingExecution.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
|
||||
List.of(ExecutionStatus.BLOCKED.getId(), ExecutionStatus.NEEDS_RECONCILIATION.getId(), ExecutionStatus.FAILED.getId()))).withLimit(25)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingExecution.TABLE_NAME, r.getValue("id"), "Execution #" + r.getValue("id")),
|
||||
"what", ExecutionStatus.getById(r.getValueInteger("status")).getLabel() + ": " + StringUtils.safeTruncate(r.getValueString("statusDetail"), 160, "..."),
|
||||
"since", String.valueOf(r.getValue("modifyDate"))));
|
||||
"since", when(r.getValue("modifyDate"))));
|
||||
}
|
||||
for(QRecord r : MarketingStore.query(MarketingFinding.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, FindingStatus.OPEN.getId()),
|
||||
new QFilterCriteria("severity", QCriteriaOperator.EQUALS, FindingSeverity.BLOCKER.getId())).withLimit(25)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", String.valueOf(r.getValue("createDate"))));
|
||||
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", when(r.getValue("createDate"))));
|
||||
}
|
||||
for(QRecord r : MarketingStore.query(MarketingAgentRun.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.FAILED.getId()),
|
||||
new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, Instant.now().minus(7, ChronoUnit.DAYS))).withLimit(10)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingAgentRun.TABLE_NAME, r.getValue("id"), "Agent run #" + r.getValue("id")),
|
||||
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", String.valueOf(r.getValue("createDate"))));
|
||||
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
|
||||
}
|
||||
|
||||
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
|
||||
@@ -438,9 +455,25 @@ public final class MarketingWidgets
|
||||
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.ACTIVE.getId())));
|
||||
|
||||
String approval = active.isEmpty() ? "Not approved."
|
||||
: "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " at " + active.get(0).getValue("approvedAt")
|
||||
String approval;
|
||||
if(!active.isEmpty())
|
||||
{
|
||||
approval = "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " on " + when(active.get(0).getValue("approvedAt"))
|
||||
+ (content.getContentHash() != null && content.getContentHash().equals(active.get(0).getValueString("contentHash")) ? " (matches this revision)." : " (does NOT match this revision).");
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// an approval an execution used up is not "not approved": say so //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> consumed = MarketingStore.query(MarketingApproval.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.CONSUMED.getId()),
|
||||
new QFilterCriteria("contentHash", QCriteriaOperator.EQUALS, content.getContentHash())));
|
||||
approval = consumed.isEmpty() ? "Not approved."
|
||||
: "Approved by " + escape(consumed.get(0).getValueString("approvedByUserId")) + " on " + when(consumed.get(0).getValue("approvedAt"))
|
||||
+ ", and used by an execution. Approve again to send this revision again.";
|
||||
}
|
||||
|
||||
StringBuilder html = new StringBuilder("<div style=\"max-width:100%;overflow-wrap:anywhere\">");
|
||||
html.append("<p><strong>").append(escape(content.getTitle())).append("</strong></p>");
|
||||
@@ -450,7 +483,7 @@ public final class MarketingWidgets
|
||||
item(html, "Call to action", content.getCallToAction());
|
||||
item(html, "Destination", content.getDestinationUrl());
|
||||
item(html, "Recipients", StringUtils.hasContent(content.getRecipientScope()) ? content.getRecipientScope() : "public (no recipient list)");
|
||||
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : content.getScheduledFor().toString());
|
||||
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : when(content.getScheduledFor()));
|
||||
item(html, "Revision", content.getRevisionNumber() + " by " + label(AuthorType.getById(content.getAuthorType())));
|
||||
item(html, "Content hash", content.getContentHash() == null ? "" : content.getContentHash().substring(0, 12));
|
||||
item(html, "Status", label(ContentStatus.getById(content.getStatus())));
|
||||
|
||||
@@ -253,6 +253,13 @@ class EndToEndFlowTest extends BaseMarketingTest
|
||||
assertThat(deliveries.get(0).getValueString("body")).as("exactly the approved text").isEqualTo(body);
|
||||
assertThat(reload(MarketingApproval.TABLE_NAME, approval.getValueInteger("id")).getValueInteger("status")).isEqualTo(ApprovalStatus.CONSUMED.getId());
|
||||
assertThat(reload(MarketingContent.TABLE_NAME, contentId).getValueInteger("status")).isEqualTo(ContentStatus.ACCEPTED.getId());
|
||||
com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput proposalInput = new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()
|
||||
.withWidgetMetaData(com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getWidget(com.kingsrook.qbits.marketing.widgets.MarketingWidgets.CONTENT_PROPOSAL));
|
||||
proposalInput.setQueryParams(new java.util.HashMap<>(java.util.Map.of("id", String.valueOf(contentId))));
|
||||
String proposal = ((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) new com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction()
|
||||
.execute(proposalInput).getWidgetData()).getHtml();
|
||||
assertThat(proposal).as("a used-up approval is not shown as not approved").contains("used by an execution").doesNotContain("Not approved")
|
||||
.containsPattern("[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4} [0-9]{1,2}:[0-9]{2} [AP]M UTC");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// 7. recorded outcomes: the provider acceptance (test data) and //
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway.CrmFormSubmissionRef;
|
||||
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway.CrmRecipient;
|
||||
import com.kingsrook.qbits.marketing.crm.QbitCrmGateway;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QbitCrmGateway against fixture tables shaped like qbit-crm 0.2.0's crm_*
|
||||
** tables.
|
||||
*******************************************************************************/
|
||||
class QbitCrmGatewayTest extends BaseMarketingTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotAvailableWithoutCrmTables()
|
||||
{
|
||||
assertThat(new QbitCrmGateway().isAvailable()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCrmFlows() throws QException
|
||||
{
|
||||
defineSnakeCaseTables();
|
||||
assertThat(QbitCrmGateway.CONTACT_TABLE).isEqualTo("crm_contact");
|
||||
exerciseGateway();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Eligibility, recipient scopes, form submissions and activities.
|
||||
*******************************************************************************/
|
||||
private void exerciseGateway() throws QException
|
||||
{
|
||||
QbitCrmGateway gateway = new QbitCrmGateway();
|
||||
assertThat(gateway.isAvailable()).isTrue();
|
||||
|
||||
Integer granted = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "yes@example.org").withValue("doNotEmail", false));
|
||||
Integer noConsent = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "none@example.org").withValue("doNotEmail", false));
|
||||
Integer suppressed = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "dne@example.org").withValue("doNotEmail", true));
|
||||
for(Integer contactId : List.of(granted, suppressed))
|
||||
{
|
||||
insert(QbitCrmGateway.CONSENT_TABLE, new QRecord().withValue("contactId", contactId).withValue("consentType", 1).withValue("status", 1)
|
||||
.withValue("consentDate", Instant.now().minus(2, ChronoUnit.DAYS)));
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// checkEmailEligibility //
|
||||
/////////////////////////////
|
||||
assertThat(gateway.checkEmailEligibility(granted).eligible()).isTrue();
|
||||
assertThat(gateway.checkEmailEligibility(noConsent).reason()).isEqualTo("no_email_consent");
|
||||
assertThat(gateway.checkEmailEligibility(suppressed).reason()).isEqualTo("do_not_email");
|
||||
assertThat(gateway.checkEmailEligibility(9999).reason()).isEqualTo("contact_not_found");
|
||||
|
||||
///////////////////////////////////////
|
||||
// resolveRecipients: contact scope //
|
||||
///////////////////////////////////////
|
||||
List<CrmRecipient> single = gateway.resolveRecipients("crm-contact:" + granted);
|
||||
assertThat(single).hasSize(1);
|
||||
assertThat(single.get(0).address()).isEqualTo("yes@example.org");
|
||||
|
||||
/////////////////////////////////////
|
||||
// resolveRecipients: list scope //
|
||||
/////////////////////////////////////
|
||||
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", granted).withValue("status", 1));
|
||||
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", noConsent).withValue("status", 1));
|
||||
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", suppressed).withValue("status", 2));
|
||||
List<CrmRecipient> members = gateway.resolveRecipients("crm-list:7");
|
||||
assertThat(members).hasSize(3);
|
||||
assertThat(members.stream().filter(r -> r.eligibility().eligible()).map(CrmRecipient::address)).containsExactly("yes@example.org");
|
||||
assertThat(members.stream().map(r -> r.eligibility().reason())).contains("no_email_consent", "list_member_not_subscribed");
|
||||
|
||||
///////////////////////////
|
||||
// listFormSubmissions //
|
||||
///////////////////////////
|
||||
Instant created = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
insert(QbitCrmGateway.FORM_SUBMISSION_TABLE, new QRecord().withValue("contactId", granted).withValue("utmCampaign", "spring").withValue("createDate", created));
|
||||
insert(QbitCrmGateway.FORM_SUBMISSION_TABLE, new QRecord().withValue("contactId", noConsent).withValue("utmCampaign", "other").withValue("createDate", created));
|
||||
List<CrmFormSubmissionRef> submissions = gateway.listFormSubmissions("spring", null);
|
||||
assertThat(submissions).hasSize(1);
|
||||
assertThat(submissions.get(0).contactId()).isEqualTo(granted);
|
||||
assertThat(gateway.listFormSubmissions("spring", Instant.now())).isEmpty();
|
||||
|
||||
//////////////////////
|
||||
// recordActivity //
|
||||
//////////////////////
|
||||
assertThatThrownBy(() -> gateway.recordActivity(granted, "Subject", "Body")).hasMessageContaining("Note");
|
||||
insert(QbitCrmGateway.ACTIVITY_TYPE_TABLE, new QRecord().withValue("name", "Note"));
|
||||
gateway.recordActivity(granted, "Subject", "Body");
|
||||
List<QRecord> activities = MarketingStore.query(QbitCrmGateway.ACTIVITY_TABLE, new QQueryFilter());
|
||||
assertThat(activities).hasSize(1);
|
||||
assertThat(activities.get(0).getValueString("subject")).isEqualTo("Subject");
|
||||
assertThat(activities.get(0).getValueInteger("contactId")).isEqualTo(granted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Integer insert(String table, QRecord record) throws QException
|
||||
{
|
||||
return (MarketingStore.insert(table, record).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void defineSnakeCaseTables()
|
||||
{
|
||||
defineCrmTables(QbitCrmGateway.CONTACT_TABLE, QbitCrmGateway.CONSENT_TABLE, QbitCrmGateway.LIST_MEMBER_TABLE,
|
||||
QbitCrmGateway.FORM_SUBMISSION_TABLE, QbitCrmGateway.ACTIVITY_TABLE, QbitCrmGateway.ACTIVITY_TYPE_TABLE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define fixture tables (memory backend) shaped like qbit-crm's.
|
||||
*******************************************************************************/
|
||||
private void defineCrmTables(String contact, String consent, String listMember, String formSubmission, String activity, String activityType)
|
||||
{
|
||||
defineTable(contact, field("email", QFieldType.STRING), field("doNotEmail", QFieldType.BOOLEAN), field("unsubscribeDate", QFieldType.DATE_TIME));
|
||||
defineTable(consent, field("contactId", QFieldType.INTEGER), field("consentType", QFieldType.INTEGER), field("status", QFieldType.INTEGER),
|
||||
field("consentDate", QFieldType.DATE_TIME), field("expiryDate", QFieldType.DATE_TIME));
|
||||
defineTable(listMember, field("emailListId", QFieldType.INTEGER), field("contactId", QFieldType.INTEGER), field("status", QFieldType.INTEGER));
|
||||
defineTable(formSubmission, field("contactId", QFieldType.INTEGER), field("utmCampaign", QFieldType.STRING), field("createDate", QFieldType.DATE_TIME));
|
||||
defineTable(activity, field("activityTypeId", QFieldType.INTEGER), field("subject", QFieldType.STRING), field("description", QFieldType.STRING),
|
||||
field("contactId", QFieldType.INTEGER), field("ownerUserId", QFieldType.STRING), field("isCompleted", QFieldType.BOOLEAN), field("completedDate", QFieldType.DATE_TIME));
|
||||
defineTable(activityType, field("name", QFieldType.STRING));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void defineTable(String name, QFieldMetaData... fields)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(name).withBackendName(BACKEND).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER));
|
||||
for(QFieldMetaData field : fields)
|
||||
{
|
||||
table.withField(field);
|
||||
}
|
||||
qInstance.addTable(table);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user