Compare commits

...
Author SHA1 Message Date
james.maes acc8e1a40a Merge remote-tracking branch 'origin/develop' into feature/demo-server 2026-10-14 12:01:41 -05:00
james.maesandClaude Opus 5.5 2adf563a4a test: local demo server for screenshots and manual checks
MarketingDemoServer (test sources, not shipped): H2 in memory, mock login
with every marketing permission, the verified starter data, and history
produced through the real actions (readiness, an agent draft on the
scripted provider, review, approval, schedule, a test-transport send).
Run it with the Next UI dev server; see its javadoc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:01:40 -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
8 changed files with 543 additions and 17 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.
+6
View File
@@ -105,6 +105,12 @@
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<!-- tests only: the local demo server (src/test/.../demo/MarketingDemoServer) -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
@@ -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;
@@ -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);
}
}
@@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Synchronous scripted responses and request snapshots for marketing tests.
*******************************************************************************/
class ScriptedModelProvider implements AssistantModelProvider
public class ScriptedModelProvider implements AssistantModelProvider
{
private final String name;
private final Deque<Step> steps = new ArrayDeque<>();
@@ -51,7 +51,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Register under the name selected by the test configuration.
*******************************************************************************/
ScriptedModelProvider(String name)
public ScriptedModelProvider(String name)
{
this.name = name;
}
@@ -61,7 +61,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a complete answer.
*******************************************************************************/
ScriptedModelProvider thenText(String text)
public ScriptedModelProvider thenText(String text)
{
return (thenRespond(request -> new ModelResponse().withParts(List.of(ModelPart.text(text))).withStopReason(ModelResponse.STOP_END_TURN)));
}
@@ -71,7 +71,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a single tool call.
*******************************************************************************/
ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
public ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
{
return (thenToolUses(null, List.<Object[]>of(new Object[] { toolName, input })));
}
@@ -82,7 +82,7 @@ class ScriptedModelProvider implements AssistantModelProvider
** Queue parallel tool calls using the test fixtures' name/input pairs.
*******************************************************************************/
@SuppressWarnings("unchecked")
ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
public ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
{
return (thenRespond(request ->
{
@@ -104,7 +104,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a provider failure, including usage already incurred.
*******************************************************************************/
ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
public ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
{
return (thenRespond(request ->
{
@@ -117,7 +117,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a response that can inspect the request or change test state.
*******************************************************************************/
ScriptedModelProvider thenRespond(Step step)
public ScriptedModelProvider thenRespond(Step step)
{
steps.add(step);
return (this);
@@ -128,7 +128,7 @@ class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Set usage for budget and cost assertions.
*******************************************************************************/
ScriptedModelProvider withUsagePerCall(ModelUsage usage)
public ScriptedModelProvider withUsagePerCall(ModelUsage usage)
{
usagePerCall = usage;
return (this);
@@ -0,0 +1,317 @@
/*
* 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.demo;
import java.io.InputStream;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
import com.kingsrook.qbits.marketing.MarketingQBitProducer;
import com.kingsrook.qbits.marketing.MarketingStore;
import com.kingsrook.qbits.marketing.ScriptedModelProvider;
import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
import com.kingsrook.qbits.marketing.model.MarketingChannel;
import com.kingsrook.qbits.marketing.model.MarketingClaim;
import com.kingsrook.qbits.marketing.model.MarketingContent;
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
import com.kingsrook.qbits.marketing.model.enums.AgentTask;
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
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.model.metadata.branding.QBrandingMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
/*******************************************************************************
** A local demo of the Marketing qbit for screenshots and manual checks (not a
** test, not shipped): an in-memory H2 database created from the shipped DDL
** (src/test/resources/marketing-h2.sql), a mock login as a user holding every
** marketing permission, the verified starter data, and some history made
** through the real services: a readiness check, an agent draft (offline,
** scripted model), review, approval, scheduling, a controlled test execution
** with its attempt and test delivery, and a few outcome events.
**
** Run it (from the repository root), then the Next UI dev server against it:
**
** ./mvnw -q test-compile
** ./mvnw -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test
** java -cp "target/test-classes;target/classes;$(cat target/cp.txt)" com.kingsrook.qbits.marketing.demo.MarketingDemoServer
**
** (Use ':' instead of ';' in the classpath on Linux/macOS. The server listens
** on port 8000, or MARKETING_DEMO_PORT.)
**
** QQQ_BACKEND_URL=http://localhost:8000 pnpm dev (in qqq-frontend-next; port 3000)
*******************************************************************************/
public class MarketingDemoServer extends AbstractQQQApplication
{
static final String BACKEND = "demo";
static final String USER = "dana";
private static final String BODY = "QQQ 4.0 is on Maven Central: you can try it with the quickstart in a few minutes. Java 21, Docker and Bash are required.";
/*******************************************************************************
** Start the demo.
*******************************************************************************/
public static void main(String[] args) throws Exception
{
MarketingDemoServer application = new MarketingDemoServer();
new QApplicationJavalinServer(application)
.withServeFrontendNext(false)
.withServeFrontendMaterialDashboard(false)
.withPort(Integer.parseInt(System.getenv().getOrDefault("MARKETING_DEMO_PORT", "8000")))
.start();
}
/*******************************************************************************
** The demo instance (seeded on definition).
*******************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
QInstance qInstance = new QInstance();
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(BACKEND).withVendor("h2").withHostName("mem").withDatabaseName("marketing_demo").withUsername("sa");
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
qInstance.addBackend(backend);
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
qInstance.setBranding(new QBrandingMetaData().withAppName("Marketing demo").withCompanyName("QRun-IO"));
qInstance.setEnforcePermissionsInCoreActions(true);
createTables(backend);
ScriptedModelProvider model = new ScriptedModelProvider("scripted").withUsagePerCall(new ModelUsage().withInputTokens(1200L).withOutputTokens(300L));
new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig().withBackendName(BACKEND).withModelProvider(model).withModel("scripted-model")).produce(qInstance);
seed(qInstance, model);
return (qInstance);
}
/*******************************************************************************
** Create the tables from the shipped H2 DDL.
*******************************************************************************/
static void createTables(RDBMSBackendMetaData backend) throws QException
{
try(Connection connection = ConnectionManager.getConnection(backend); Statement statement = connection.createStatement();
InputStream ddl = MarketingDemoServer.class.getResourceAsStream("/marketing-h2.sql"))
{
String sql = new String(ddl.readAllBytes(), StandardCharsets.UTF_8);
for(String part : sql.split(";"))
{
String trimmed = part.lines().filter(l -> !l.trim().startsWith("--")).reduce("", (a, b) -> a + "\n" + b).trim();
if(!trimmed.isEmpty())
{
statement.execute(trimmed);
}
}
}
catch(Exception e)
{
throw (new QException("Could not create the demo tables", e));
}
}
/*******************************************************************************
** Starter data, then history through the real services and processes.
*******************************************************************************/
static void seed(QInstance qInstance, ScriptedModelProvider model) throws QException
{
try
{
QContext.init(qInstance, DemoSessionCustomizer.sessionFor(USER));
////////////////////////////////////////////////////////////////
// the qbit's own "Load Verified Starter Data" table action //
////////////////////////////////////////////////////////////////
requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.LOAD_STARTER_DATA)
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "starter data");
QRecord product = first(MarketingProduct.TABLE_NAME, "slug", "qqq");
QRecord campaign = first(MarketingCampaign.TABLE_NAME, "name", "QQQ 4.0: from quickstart to your first table");
QRecord sink = first(MarketingChannel.TABLE_NAME, "name", "Controlled test sink");
Integer campaignId = campaign.getValueInteger("id");
requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, campaignId), "campaign readiness");
requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, first(MarketingCampaign.TABLE_NAME, "name", "gclo 1.1 for Windows: mirror a whole GitHub org").getValue("id")), "campaign readiness");
///////////////////////////////////////////////////////////////////////////
// Draft with Agent, offline: the scripted model reads, then saves a draft //
///////////////////////////////////////////////////////////////////////////
Integer released = MarketingStore.query(MarketingClaim.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("productId", QCriteriaOperator.EQUALS, product.getValue("id")),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ClaimStatus.RELEASED.getId()))).get(0).getValueInteger("id");
model.thenToolUses("Reading the brief first.", List.of(
new Object[] { MarketingTools.GET_CAMPAIGN_BRIEF, Map.of("campaign_id", campaignId) },
new Object[] { MarketingTools.LIST_PRODUCT_CLAIMS, Map.of("product_id", product.getValueInteger("id")) },
new Object[] { MarketingTools.LIST_CHANNELS, Map.of() }))
.thenToolUse(MarketingTools.SAVE_DRAFT, Map.of("campaign_id", campaignId, "channel_id", sink.getValueInteger("id"), "title", "Try QQQ 4.0 in a few minutes",
"body", BODY, "claim_ids", List.of(released)))
.thenText("I saved one draft for review on the controlled test sink.");
new MarketingAgentService().run(AgentTask.DRAFT_CONTENT, campaignId, null, null);
///////////////////////////////////////////////////////////////////////////
// a person schedules it, submits it, approves it and schedules it; the //
// worker then runs the due execution on the controlled test sink //
///////////////////////////////////////////////////////////////////////////
Integer contentId = first(MarketingContent.TABLE_NAME, "campaignId", campaignId).getValueInteger("id");
MarketingStore.update(MarketingContent.TABLE_NAME, new QRecord().withValue("id", contentId).withValue("scheduledFor", Instant.now().minusSeconds(5)));
requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, contentId), "submit for review");
requireNoException(runAction(MarketingActions.APPROVE, contentId), "approve");
requireNoException(runAction(MarketingActions.SCHEDULE, contentId), "schedule");
requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.RUN_DUE_EXECUTIONS)
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "run due executions");
///////////////////////////////////////////////////
// a second piece of content, waiting for review //
///////////////////////////////////////////////////
Integer waiting = MarketingStore.insert(new MarketingContent().withCampaignId(campaignId).withChannelId(sink.getValueInteger("id")).withTitle("Your first table, step by step")
.withBody("The quickstart takes you from an empty project to your first table. Java 21, Docker and Bash are required.")
.withScheduledFor(Instant.now().plusSeconds(3600L * 24))).getValueInteger("id");
requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, waiting), "submit for review");
/////////////////////////////////////////////////////////////////////////
// controlled fixture events (test data, never counted as real numbers) //
/////////////////////////////////////////////////////////////////////////
for(int i = 1; i <= 5; i++)
{
MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.AWARENESS.getId()).withEventType("try_page_visit")
.withSource("fixture").withExternalEventId("visit-" + i).withActorKey("visitor-" + (i % 3)).withIsTestData(true));
}
MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.ENGAGEMENT.getId()).withEventType("quickstart_opened")
.withSource("fixture").withExternalEventId("open-1").withActorKey("visitor-1").withIsTestData(true));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The first record matching one field.
*******************************************************************************/
private static QRecord first(String table, String field, Serializable value) throws QException
{
return (MarketingStore.queryBy(table, field, value).get(0));
}
/*******************************************************************************
** Run a record action headlessly on one record.
*******************************************************************************/
private static RunProcessOutput runAction(String processName, Serializable id) throws QException
{
return (new RunProcessAction().execute(new RunProcessInput().withProcessName(processName).withCallback(QProcessCallbackFactory.forPrimaryKey("id", id))
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)));
}
/*******************************************************************************
** Fail the demo start-up if a seeding step failed.
*******************************************************************************/
private static void requireNoException(RunProcessOutput output, String what) throws QException
{
if(output.getException().isPresent())
{
throw (new QException("Demo seeding failed at [" + what + "]", output.getException().get()));
}
}
/*******************************************************************************
** Every mock session is Dana, who holds every marketing permission.
*******************************************************************************/
public static class DemoSessionCustomizer implements QAuthenticationModuleCustomizerInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
QSession granted = sessionFor(USER);
qSession.setUser(granted.getUser());
qSession.setPermissions(granted.getPermissions());
}
/***************************************************************************
**
***************************************************************************/
@Override
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession automatedSessionForUser, Serializable userId)
{
automatedSessionForUser.setPermissions(sessionFor(String.valueOf(userId)).getPermissions());
}
/***************************************************************************
** Dana's session.
***************************************************************************/
static QSession sessionFor(String userId)
{
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Dana Demo"));
session.setPermissions(new java.util.HashSet<>(MarketingPermissions.ALL));
return (session);
}
}
}