Compare commits

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

Found in the demo-server screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:04:59 -05:00
5 changed files with 55 additions and 338 deletions

No files matched your search

-6
View File
@@ -105,12 +105,6 @@
<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>
@@ -19,10 +19,13 @@ package com.kingsrook.qbits.marketing.widgets;
import java.io.Serializable;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.MarketingStore;
@@ -66,6 +69,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
@@ -91,6 +95,8 @@ public final class MarketingWidgets
public static final String CAMPAIGN_RESULTS = "marketing_campaign_results";
public static final String CONTENT_PROPOSAL = "marketing_content_proposal";
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a 'UTC'", Locale.US).withZone(ZoneOffset.UTC);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
@@ -196,6 +202,17 @@ public final class MarketingWidgets
/*******************************************************************************
** A date-time for people: "Oct 14, 2026 4:53 PM UTC" (empty when null).
*******************************************************************************/
static String when(Object value)
{
Instant instant = ValueUtils.getValueAsInstant(value);
return (instant == null ? "" : WHEN_FORMAT.format(instant));
}
/*******************************************************************************
**
*******************************************************************************/
@@ -242,27 +259,27 @@ public final class MarketingWidgets
for(QRecord r : MarketingStore.query(MarketingContent.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, ContentStatus.IN_REVIEW.getId())).withLimit(25)))
{
rows.add(row("item", link(MarketingContent.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Waiting for review (revision " + r.getValueInteger("revisionNumber") + ")",
"since", String.valueOf(r.getValue("modifyDate"))));
"since", when(r.getValue("modifyDate"))));
}
for(QRecord r : MarketingStore.query(MarketingExecution.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
List.of(ExecutionStatus.BLOCKED.getId(), ExecutionStatus.NEEDS_RECONCILIATION.getId(), ExecutionStatus.FAILED.getId()))).withLimit(25)))
{
rows.add(row("item", link(MarketingExecution.TABLE_NAME, r.getValue("id"), "Execution #" + r.getValue("id")),
"what", ExecutionStatus.getById(r.getValueInteger("status")).getLabel() + ": " + StringUtils.safeTruncate(r.getValueString("statusDetail"), 160, "..."),
"since", String.valueOf(r.getValue("modifyDate"))));
"since", when(r.getValue("modifyDate"))));
}
for(QRecord r : MarketingStore.query(MarketingFinding.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("status", QCriteriaOperator.EQUALS, FindingStatus.OPEN.getId()),
new QFilterCriteria("severity", QCriteriaOperator.EQUALS, FindingSeverity.BLOCKER.getId())).withLimit(25)))
{
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", String.valueOf(r.getValue("createDate"))));
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", when(r.getValue("createDate"))));
}
for(QRecord r : MarketingStore.query(MarketingAgentRun.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.FAILED.getId()),
new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, Instant.now().minus(7, ChronoUnit.DAYS))).withLimit(10)))
{
rows.add(row("item", link(MarketingAgentRun.TABLE_NAME, r.getValue("id"), "Agent run #" + r.getValue("id")),
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", String.valueOf(r.getValue("createDate"))));
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
}
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
@@ -438,9 +455,25 @@ public final class MarketingWidgets
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.ACTIVE.getId())));
String approval = active.isEmpty() ? "Not approved."
: "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " at " + active.get(0).getValue("approvedAt")
String approval;
if(!active.isEmpty())
{
approval = "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " on " + when(active.get(0).getValue("approvedAt"))
+ (content.getContentHash() != null && content.getContentHash().equals(active.get(0).getValueString("contentHash")) ? " (matches this revision)." : " (does NOT match this revision).");
}
else
{
//////////////////////////////////////////////////////////////////////
// an approval an execution used up is not "not approved": say so //
//////////////////////////////////////////////////////////////////////
List<QRecord> consumed = MarketingStore.query(MarketingApproval.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.CONSUMED.getId()),
new QFilterCriteria("contentHash", QCriteriaOperator.EQUALS, content.getContentHash())));
approval = consumed.isEmpty() ? "Not approved."
: "Approved by " + escape(consumed.get(0).getValueString("approvedByUserId")) + " on " + when(consumed.get(0).getValue("approvedAt"))
+ ", and used by an execution. Approve again to send this revision again.";
}
StringBuilder html = new StringBuilder("<div style=\"max-width:100%;overflow-wrap:anywhere\">");
html.append("<p><strong>").append(escape(content.getTitle())).append("</strong></p>");
@@ -450,7 +483,7 @@ public final class MarketingWidgets
item(html, "Call to action", content.getCallToAction());
item(html, "Destination", content.getDestinationUrl());
item(html, "Recipients", StringUtils.hasContent(content.getRecipientScope()) ? content.getRecipientScope() : "public (no recipient list)");
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : content.getScheduledFor().toString());
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : when(content.getScheduledFor()));
item(html, "Revision", content.getRevisionNumber() + " by " + label(AuthorType.getById(content.getAuthorType())));
item(html, "Content hash", content.getContentHash() == null ? "" : content.getContentHash().substring(0, 12));
item(html, "Status", label(ContentStatus.getById(content.getStatus())));
@@ -253,6 +253,13 @@ class EndToEndFlowTest extends BaseMarketingTest
assertThat(deliveries.get(0).getValueString("body")).as("exactly the approved text").isEqualTo(body);
assertThat(reload(MarketingApproval.TABLE_NAME, approval.getValueInteger("id")).getValueInteger("status")).isEqualTo(ApprovalStatus.CONSUMED.getId());
assertThat(reload(MarketingContent.TABLE_NAME, contentId).getValueInteger("status")).isEqualTo(ContentStatus.ACCEPTED.getId());
com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput proposalInput = new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()
.withWidgetMetaData(com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getWidget(com.kingsrook.qbits.marketing.widgets.MarketingWidgets.CONTENT_PROPOSAL));
proposalInput.setQueryParams(new java.util.HashMap<>(java.util.Map.of("id", String.valueOf(contentId))));
String proposal = ((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) new com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction()
.execute(proposalInput).getWidgetData()).getHtml();
assertThat(proposal).as("a used-up approval is not shown as not approved").contains("used by an execution").doesNotContain("Not approved")
.containsPattern("[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4} [0-9]{1,2}:[0-9]{2} [AP]M UTC");
///////////////////////////////////////////////////////////////////
// 7. recorded outcomes: the provider acceptance (test data) and //
@@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Synchronous scripted responses and request snapshots for marketing tests.
*******************************************************************************/
public class ScriptedModelProvider implements AssistantModelProvider
class ScriptedModelProvider implements AssistantModelProvider
{
private final String name;
private final Deque<Step> steps = new ArrayDeque<>();
@@ -51,7 +51,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Register under the name selected by the test configuration.
*******************************************************************************/
public ScriptedModelProvider(String name)
ScriptedModelProvider(String name)
{
this.name = name;
}
@@ -61,7 +61,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a complete answer.
*******************************************************************************/
public ScriptedModelProvider thenText(String text)
ScriptedModelProvider thenText(String text)
{
return (thenRespond(request -> new ModelResponse().withParts(List.of(ModelPart.text(text))).withStopReason(ModelResponse.STOP_END_TURN)));
}
@@ -71,7 +71,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a single tool call.
*******************************************************************************/
public ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
{
return (thenToolUses(null, List.<Object[]>of(new Object[] { toolName, input })));
}
@@ -82,7 +82,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
** Queue parallel tool calls using the test fixtures' name/input pairs.
*******************************************************************************/
@SuppressWarnings("unchecked")
public ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
{
return (thenRespond(request ->
{
@@ -104,7 +104,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a provider failure, including usage already incurred.
*******************************************************************************/
public ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
{
return (thenRespond(request ->
{
@@ -117,7 +117,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Queue a response that can inspect the request or change test state.
*******************************************************************************/
public ScriptedModelProvider thenRespond(Step step)
ScriptedModelProvider thenRespond(Step step)
{
steps.add(step);
return (this);
@@ -128,7 +128,7 @@ public class ScriptedModelProvider implements AssistantModelProvider
/*******************************************************************************
** Set usage for budget and cost assertions.
*******************************************************************************/
public ScriptedModelProvider withUsagePerCall(ModelUsage usage)
ScriptedModelProvider withUsagePerCall(ModelUsage usage)
{
usagePerCall = usage;
return (this);
@@ -1,317 +0,0 @@
/*
* 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);
}
}
}