diff --git a/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java b/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java index 31ddd23..f53bb5b 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java +++ b/src/main/java/com/kingsrook/qbits/marketing/widgets/MarketingWidgets.java @@ -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 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 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("
"); html.append("

").append(escape(content.getTitle())).append("

"); @@ -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()))); diff --git a/src/test/java/com/kingsrook/qbits/marketing/EndToEndFlowTest.java b/src/test/java/com/kingsrook/qbits/marketing/EndToEndFlowTest.java index 589d101..a085586 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/EndToEndFlowTest.java +++ b/src/test/java/com/kingsrook/qbits/marketing/EndToEndFlowTest.java @@ -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 //