Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc8e1a40a
|
||
|
|
2adf563a4a
|
||
|
|
b4387881c8 | ||
|
|
7e6667aaff | ||
|
|
d5979d7308
|
||
|
|
a40d2ad5ed
|
||
|
|
1c0fa34e7c
|
||
|
|
b0399255df | ||
|
|
a590ae47d1
|
||
|
|
5dd68eb20b
|
||
|
|
d732cef626
|
||
|
|
995e508e6a |
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.
|
||||
@@ -13,6 +14,7 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- Upgrade QQQ to `4.1.0-RC.6`; enable architecture enforcement and strict UI API routes.
|
||||
|
||||
### Added
|
||||
- Help bundle `help/qbit-marketing.yaml` (346 entries) covering the app, tables, fields, actions, widgets and possible-value sources, with `HelpBundleCoverageTest` as the coverage gate until `HelpBundleAssertions` ships in core (qqq#1198). The producer registers it once that API exists.
|
||||
- Product-contingent possible values for editable campaign, claim, finding, metric and outcome-event references.
|
||||
- Repository scaffold: build, CI and the repository standard files.
|
||||
- **Model:** 18 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -98,6 +98,12 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
|
||||
- Content: Proposed Publication.
|
||||
- **Actions:** every action is a streamed-ETL record action (record input, standard Review and Result summary lines) in the table's or record's Actions menu. None are nav items.
|
||||
|
||||
## Help
|
||||
|
||||
The help text ships with the code it describes, as a QQQ help bundle at `help/qbit-marketing.yaml` (a classpath resource, layer `QBIT`). It has 346 entries: the Marketing app, every `marketing_*` table and each of its non-trivial fields, every record and table action, every widget (the dashboard widgets and the child lists) and every possible-value source. Each entry has a one-line summary for the tooltip and a few sentences of plain help. Labels, maximum lengths and enum values are `{{...}}` references, so the help does not repeat what the metadata already says.
|
||||
|
||||
The bundle is loaded with `HelpBundles.register(qInstance, "help/qbit-marketing.yaml")` once qqq#1198 ships. The producer will register it then; until that core API exists, nothing registers it. `HelpBundleCoverageTest` checks that the bundle is complete, that every key and reference resolves, and that summaries stay short and plain.
|
||||
|
||||
## Email through qbit-email
|
||||
|
||||
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is produced in the same instance, email channels named `qqq_email` (the starter-data channel) send through it automatically, unless the host registered its own transport under that name. `QqqMessagingEmailTransport` stays the fallback; without qbit-email nothing changes.
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,421 @@
|
||||
/*
|
||||
* 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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with
|
||||
** HelpBundleAssertions.forInstance(qInstance).bundles("help/qbit-marketing.yaml").owning(...).assertComplete()
|
||||
** once qqq#1198 ships.
|
||||
**
|
||||
** The help bundle (help/qbit-marketing.yaml) covers everything the qbit
|
||||
** produces, and every entry is sound:
|
||||
** - the app, every marketing_* table, every non-trivial field (all but the
|
||||
** primary key, createDate, modifyDate and hidden fields), every process,
|
||||
** every widget and every possible-value source has an entry;
|
||||
** - every entry key resolves to a real object;
|
||||
** - summaries are at most 120 characters, with no markup;
|
||||
** - every {{...}} reference resolves.
|
||||
**
|
||||
** Each test fails with the full list of missing or broken keys.
|
||||
*******************************************************************************/
|
||||
class HelpBundleCoverageTest extends BaseMarketingTest
|
||||
{
|
||||
private static final String BUNDLE_RESOURCE = "help/qbit-marketing.yaml";
|
||||
|
||||
private static final int MAX_SUMMARY_LENGTH = 120;
|
||||
private static final String MARKUP_CHARACTERS = "<>*_`[]#";
|
||||
|
||||
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bundle entry.
|
||||
*******************************************************************************/
|
||||
private record Entry(String key, String summary, String content)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle header names this qbit, at its layer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHeader() throws IOException
|
||||
{
|
||||
Map<String, Object> bundle = loadBundle();
|
||||
assertThat(bundle.get("bundle")).isEqualTo("qbit-marketing");
|
||||
assertThat(bundle.get("layer")).isEqualTo("QBIT");
|
||||
assertThat(bundle.get("version")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every object the qbit owns has an entry.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryOwnedObjectHasAnEntry() throws IOException
|
||||
{
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
if(!keys.add(entry.key()))
|
||||
{
|
||||
problems.add("duplicate entry: " + entry.key());
|
||||
}
|
||||
}
|
||||
|
||||
for(String expected : expectedKeys())
|
||||
{
|
||||
if(!keys.contains(expected))
|
||||
{
|
||||
problems.add("missing entry: " + expected);
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every entry key names something that exists.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryKeyResolves() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
String problem = resolveKey(entry.key());
|
||||
if(problem != null)
|
||||
{
|
||||
problems.add(entry.key() + ": " + problem);
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summaries are short, plain text; every entry has content.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSummariesAndContent() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
String summary = entry.summary();
|
||||
if(summary == null || summary.isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": no summary");
|
||||
continue;
|
||||
}
|
||||
if(summary.length() > MAX_SUMMARY_LENGTH)
|
||||
{
|
||||
problems.add(entry.key() + ": summary is " + summary.length() + " characters (limit " + MAX_SUMMARY_LENGTH + ")");
|
||||
}
|
||||
for(char c : MARKUP_CHARACTERS.toCharArray())
|
||||
{
|
||||
if(summary.indexOf(c) >= 0)
|
||||
{
|
||||
problems.add(entry.key() + ": summary contains the markup character " + c);
|
||||
}
|
||||
}
|
||||
if(entry.content() == null || entry.content().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": no content");
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every {{...}} reference resolves.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryReferenceResolves() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
for(String text : new String[] { entry.summary(), entry.content() })
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = REFERENCE.matcher(text);
|
||||
while(matcher.find())
|
||||
{
|
||||
String problem = resolveReference(matcher.group(1), matcher.group(2));
|
||||
if(problem != null)
|
||||
{
|
||||
problems.add(entry.key() + ": {{" + matcher.group(1) + ":" + matcher.group(2) + "}} " + problem);
|
||||
}
|
||||
}
|
||||
if(text.replaceAll(REFERENCE.pattern(), "").contains("{{"))
|
||||
{
|
||||
problems.add(entry.key() + ": malformed {{ reference");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The keys the bundle must contain, from the produced instance.
|
||||
*******************************************************************************/
|
||||
private Set<String> expectedKeys()
|
||||
{
|
||||
Set<String> keys = new TreeSet<>();
|
||||
keys.add("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help");
|
||||
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(!table.getName().startsWith("marketing_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
keys.add("table:" + table.getName() + ";slot:help");
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(isNonTrivial(table, field))
|
||||
{
|
||||
keys.add("table:" + table.getName() + ";field:" + field.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the qbit's own processes (QQQ also generates bulk table processes) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(QProcessMetaData process : qInstance.getProcesses().values())
|
||||
{
|
||||
if(process.getSourceQBitName() != null)
|
||||
{
|
||||
keys.add("process:" + process.getName() + ";slot:help");
|
||||
}
|
||||
}
|
||||
|
||||
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("widget:" + n + ";slot:label"));
|
||||
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("possibleValueSource:" + n + ";slot:help"));
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every field except the primary key, createDate, modifyDate and hidden ones.
|
||||
*******************************************************************************/
|
||||
private static boolean isNonTrivial(QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
String name = field.getName();
|
||||
return (!name.equals(table.getPrimaryKeyField()) && !name.equals("createDate") && !name.equals("modifyDate") && !Boolean.TRUE.equals(field.getIsHidden()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null if the key names a real object, else why not.
|
||||
*******************************************************************************/
|
||||
private String resolveKey(String key)
|
||||
{
|
||||
Map<String, String> parts = new LinkedHashMap<>();
|
||||
for(String part : key.split(";"))
|
||||
{
|
||||
int colon = part.indexOf(':');
|
||||
if(colon < 1)
|
||||
{
|
||||
return ("malformed key part [" + part + "]");
|
||||
}
|
||||
parts.put(part.substring(0, colon), part.substring(colon + 1));
|
||||
}
|
||||
|
||||
if(parts.containsKey("app"))
|
||||
{
|
||||
return (qInstance.getApp(parts.get("app")) == null ? "no such app" : slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("table"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(parts.get("table"));
|
||||
if(table == null)
|
||||
{
|
||||
return ("no such table");
|
||||
}
|
||||
if(parts.containsKey("field"))
|
||||
{
|
||||
return (table.getFields().containsKey(parts.get("field")) ? null : "no such field");
|
||||
}
|
||||
if(parts.containsKey("section"))
|
||||
{
|
||||
return (table.getSections() != null && table.getSections().stream().map(QFieldSection::getName).anyMatch(parts.get("section")::equals) ? null : "no such section");
|
||||
}
|
||||
return (slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("process"))
|
||||
{
|
||||
QProcessMetaData process = qInstance.getProcess(parts.get("process"));
|
||||
if(process == null)
|
||||
{
|
||||
return ("no such process");
|
||||
}
|
||||
if(parts.containsKey("step"))
|
||||
{
|
||||
return (process.getStep(parts.get("step")) == null ? "no such step" : null);
|
||||
}
|
||||
return (parts.containsKey("field") ? "process fields are not covered by this bundle" : slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("widget"))
|
||||
{
|
||||
return (qInstance.getWidget(parts.get("widget")) == null ? "no such widget" : slotProblem(parts, "label"));
|
||||
}
|
||||
if(parts.containsKey("possibleValueSource"))
|
||||
{
|
||||
return (qInstance.getPossibleValueSource(parts.get("possibleValueSource")) == null ? "no such possible-value source" : slotProblem(parts, "help"));
|
||||
}
|
||||
return ("unsupported key");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The slot must be the expected one.
|
||||
*******************************************************************************/
|
||||
private static String slotProblem(Map<String, String> parts, String expectedSlot)
|
||||
{
|
||||
return (expectedSlot.equals(parts.get("slot")) ? null : "expected slot:" + expectedSlot);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null if the live reference resolves, else why not.
|
||||
*******************************************************************************/
|
||||
private String resolveReference(String kind, String target)
|
||||
{
|
||||
String[] tableAndField = target.split("\\.", 2);
|
||||
QTableMetaData table = qInstance.getTable(tableAndField[0]);
|
||||
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
|
||||
|
||||
switch(kind)
|
||||
{
|
||||
case "label":
|
||||
{
|
||||
if(tableAndField.length == 2)
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : null);
|
||||
}
|
||||
boolean found = table != null || qInstance.getProcess(target) != null || qInstance.getApp(target) != null;
|
||||
return (found ? null : "does not resolve to a table, process or app");
|
||||
}
|
||||
case "default":
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : (field.getDefaultValue() == null ? "the field has no default value" : null));
|
||||
}
|
||||
case "maxLength":
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : (field.getMaxLength() == null ? "the field has no maximum length" : null));
|
||||
}
|
||||
case "values":
|
||||
{
|
||||
QPossibleValueSource source = qInstance.getPossibleValueSource(target);
|
||||
return (source == null ? "does not resolve to a possible-value source" : (source.getType() == QPossibleValueSourceType.ENUM ? null : "is not an enum source"));
|
||||
}
|
||||
default:
|
||||
{
|
||||
return ("unsupported reference kind");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle, as parsed YAML.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> loadBundle() throws IOException
|
||||
{
|
||||
try(InputStream inputStream = HelpBundleCoverageTest.class.getClassLoader().getResourceAsStream(BUNDLE_RESOURCE))
|
||||
{
|
||||
assertThat(inputStream).as("classpath resource " + BUNDLE_RESOURCE).isNotNull();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> bundle = new ObjectMapper(new YAMLFactory()).readValue(inputStream, Map.class);
|
||||
return (bundle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Entry> loadEntries() throws IOException
|
||||
{
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for(Map<String, Object> row : (List<Map<String, Object>>) loadBundle().get("entries"))
|
||||
{
|
||||
entries.add(new Entry(String.valueOf(row.get("key")), (String) row.get("summary"), (String) row.get("content")));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail with every problem at once.
|
||||
*******************************************************************************/
|
||||
private static void assertNoProblems(List<String> problems)
|
||||
{
|
||||
assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user