Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 5b2589b1d5 fix(widgets): readable dates, and a used approval is not "Not approved"
Needs Attention and Proposed Publication printed raw ISO instants; they
now read like "Oct 14, 2026 4:53 PM UTC". Proposed Publication said
"Not approved" for content whose approval an execution had consumed; it
now says who approved it and that an execution used it.

Found in the demo-server screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:04:59 -05:00
james.maes b4387881c8 Merge pull request #19 from QRun-IO/feature/help-bundle
feat(help): help bundle (qqq#1198 format) with a coverage gate
2026-10-14 11:56:30 -05:00
james.maes 7e6667aaff Merge pull request #17 from QRun-IO/feature/crm-table-names
feat(crm): qbit-crm 0.2.0 crm_* table names
2026-10-14 11:56:19 -05:00
james.maes 1c0fa34e7c Merge remote-tracking branch 'origin/develop' into feature/crm-table-names
# Conflicts:
#	CHANGELOG.md
2026-10-14 11:52:54 -05:00
james.maesandClaude Opus 5.5 5dd68eb20b refactor(crm): crm_* table names only, no legacy fallback
qbit-crm 0.2.0 renames its tables to crm_* with no compatibility shims
(owner decision via the coordinator), so the gateway uses the snake_case
names directly and drops the camelCase fallback and LEGACY_* constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:46:24 -05:00
james.maesandClaude Opus 5.5 d732cef626 feat(crm): resolve qbit-crm's snake_case table names, legacy fallback
qbit-crm is renaming its tables to crm_* snake_case. QbitCrmGateway now
resolves each table from the instance at call time, preferring the
snake_case name and falling back to the legacy camelCase one, so either
qbit-crm generation works. Tests run the gateway against both schemes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:40:48 -05:00
7 changed files with 259 additions and 16 deletions

No files matched your search

+1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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);
}
}