Compare commits

..
Author SHA1 Message Date
james.maesandCodex e361a9d133 feat: support email-owned CRM templates
Add explicit host migration opt-in with checked email rendering and outbox provenance. Adopt public email RC10 and QQQ RC20, retaining legacy adapters and caller-scoped core health checks.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 09:55:05 -05:00
james.maes a8e9a34b51 Merge pull request #22 from QRun-IO/feature/crm-setup-admin-only
fix(crm): Setup card only for settings holders while setup is incomplete
2026-10-16 10:19:22 -05:00
james.maesandClaude Sonnet 5.5 26b8847b46 fix(crm): show the Setup card only to CRM settings holders while setup is incomplete
The crm_setup widget was the 2nd widget on the CRM home, rendered to every
CRM user even when complete ("Nothing to do"), and carried a provider row
(Email delivery: sends through the email qbit). That is system/config content
on a business home.

- crm_setup now requires crm.manage (the permission guarding the CRM Settings
  tables) via widget rules, plus CrmSetupVisibility, a request-time custom
  permission checker (same mechanism as crm_goals) that denies once no
  business step is missing. Denied widgets are omitted (DenyBehavior.HIDDEN)
  from metadata and render.
- Removed the Email delivery row; the checklist is business setup only.
- Help text updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:10:51 -05:00
james.maes 820474805d Merge pull request #21 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:28 -05:00
james.maesandClaude Opus 5.5 5157f81208 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:11 -05:00
james.maes c6031c26ed Merge pull request #20 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 08e880af2e fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-crm.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:22:58 -05:00
james.maes e3f20d357e Merge pull request #18 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
23 changed files with 637 additions and 46 deletions

No files matched your search

+1
View File
@@ -21,5 +21,6 @@ jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
secrets: inherit
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+5
View File
@@ -2,6 +2,11 @@
## Unreleased / 0.2.0
- Add explicit shared email-template opt-in using qbit-email 0.1.0-RC.10. Email owns rendering and checked outbox handoff; CRM retains caller-visible variables, delivery categories and exact sent-subject history. Legacy template metadata is omitted only for opted-in hosts; tenant hosts retain the existing path.
- Adopt QQQ 4.1.0-RC.20 and register native CRM health checks while preserving legacy health adapters and caller/tenant context.
- Read the CRM QBit metadata version from the Maven-filtered artifact version so release metadata matches the build.
- Adopt QQQ 4.1.0-RC.7 native help bundles, replacing the RC.6 stand-in loader with host-neutral CRM help, live references and the core coverage gate.
- Upgrade to Java 21, QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.2.
+13
View File
@@ -1,5 +1,18 @@
# Upgrading
## Shared email templates (Unreleased / 0.2.0)
This version uses public qbit-email `0.1.0-RC.10` and QQQ `4.1.0-RC.20`. Shared templates remain disabled by default. Existing optional-email and tenant-enabled hosts retain their legacy template behavior.
For a non-tenant host adopting shared templates:
1. Install the email qbit and configure its template permission base as `crm.manage`. Existing per-template edit permissions and record locks remain enforced; this grant does not expose other email settings.
2. Before enabling `CrmQBitConfig.withSharedEmailTemplates(true)`, migrate legacy template content into email-owned `CRM_LEGACY` templates and active versions, remap sequence/history and generic record references, and retain source provenance and rollback data. This library does not run a host database migration. Populated deployments require a documented CRM maintenance window with writes and workers quiesced, fresh preflight counts and verified backups.
3. Enable the opt-in only after migration. It omits `crm_email_template` metadata and its possible-value source entirely and uses `email_template` for actions and sequences. Tenant mode and missing email installations reject the opt-in.
4. Native email templates retain their category and content policies. Migrated compatibility templates preserve historical substitutions and overrides. Checked replay can reject a changed template version or envelope; reconcile the existing effect rather than silently retrying with a new idempotency key.
The producer also registers `crm.email`, `crm.sequence-lag` and `crm.lead-score` with core health metadata. Existing legacy adapters remain available. Checks use execution-time context, preserve tenant visibility and do not assign tenant keys to contextless workers.
## Starter defaults (Unreleased / 0.2.0)
The CRM now ships `db/liquibase/qbit-crm/crm-defaults.changelog.sql`, a separate insert-only changelog. Without it,
+15 -3
View File
@@ -23,6 +23,7 @@
<properties>
<revision>0.2.0-SNAPSHOT</revision>
<qbit-email.version>0.1.0-RC.10</qbit-email.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -87,7 +88,7 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>0.1.0-RC.3</version>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -142,6 +143,7 @@
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-crm.properties</include>
</includes>
</resource>
<resource>
@@ -149,10 +151,20 @@
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-crm.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -221,14 +233,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.20 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.20</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -67,14 +67,15 @@ public final class CrmAppMetaData
*******************************************************************************/
public static QAppMetaData build(MetaDataProducerMultiOutput output, String source)
{
List<String> settings = SETTINGS_TABLES.stream().filter(name -> output.get(QTableMetaData.class, name) != null).toList();
QAppMetaData app = new QAppMetaData().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
.withWidgets(new ArrayList<>(CrmHomeWidgets.HOME))
.withSection(mainSection())
.withSection(new QAppSection().withName("crm_settings").withLabel("Settings").withIcon(new QIcon("settings"))
.withTables(new ArrayList<>(SETTINGS_TABLES)));
.withTables(new ArrayList<>(settings)));
List<String> tables = new ArrayList<>(MAIN_TABLES);
tables.addAll(SETTINGS_TABLES);
tables.addAll(settings);
for(String name : tables)
{
app.withChild(output.get(QTableMetaData.class, name));
@@ -41,6 +41,7 @@ public class CrmQBitConfig implements QBitConfig
{
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
private boolean sharedEmailTemplates;
private String tenantSecurityKeyType;
private String tenantAllAccessKeyName;
private String apiName;
@@ -363,4 +364,22 @@ public class CrmQBitConfig implements QBitConfig
}
/*******************************************************************************
** Opt-in only after the host has migrated legacy template references.
*******************************************************************************/
public boolean getSharedEmailTemplates()
{
return (sharedEmailTemplates);
}
/*******************************************************************************
** Other hosts keep the legacy optional-email and tenant behavior by default.
*******************************************************************************/
public CrmQBitConfig withSharedEmailTemplates(boolean sharedEmailTemplates)
{
this.sharedEmailTemplates = sharedEmailTemplates;
return (this);
}
}
@@ -26,9 +26,12 @@
package com.kingsrook.qbits.crm;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.customizers.CurrencyRateDateCustomizer;
@@ -69,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
@@ -86,12 +90,40 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "crm";
public static final String VERSION = "0.2.0-SNAPSHOT";
public static final String VERSION = loadVersion();
private CrmQBitConfig qBitConfig;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CrmQBitProducer.class.getClassLoader().getResourceAsStream("qbit-crm.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-crm.properties; build with Maven to provide the artifact version");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-crm.properties; build with Maven to provide the artifact version");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read artifact version from qbit-crm.properties", e);
}
}
/*******************************************************************************
** Produce the QBitMetaData for the CRM QBit.
*******************************************************************************/
@@ -360,10 +392,28 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
if(qBitConfig.getSharedEmailTemplates() && (qBitConfig.getTenantSecurityKeyType() != null || qInstance.getTable("email_template") == null))
{
throw (new QException("Shared CRM templates require installed qbit-email and a non-tenant CRM host"));
}
MetaDataProducerMultiOutput produced = QBitMetaDataProducer.super.produce(qInstance);
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
for(MetaDataProducerOutput item : produced.getEach(MetaDataProducerOutput.class))
{
if(qBitConfig.getSharedEmailTemplates() && ((item instanceof QTableMetaData table && "crm_email_template".equals(table.getName()))
|| (item instanceof QPossibleValueSource source && "crm_email_template".equals(source.getName()))))
{
continue;
}
if(qBitConfig.getSharedEmailTemplates() && item instanceof QTableMetaData table && table.getFields().containsKey("emailTemplateId"))
{
table.getField("emailTemplateId").setPossibleValueSourceName("email_template");
}
if(qBitConfig.getSharedEmailTemplates() && item instanceof QProcessMetaData process)
{
process.getInputFields().stream().filter(field -> "emailTemplateId".equals(field.getName()))
.forEach(field -> field.setPossibleValueSourceName("email_template"));
}
output.add(item instanceof QWidgetMetaData widget
? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
}
@@ -385,6 +435,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
qInstance.withProcessLifecycleListener(new QCodeReference(CrmEmailProcessIdentity.class));
registerHooks(qInstance);
// Registration is metadata-only; core startup sync guards hosts without helpContent.
com.kingsrook.qbits.crm.health.CrmHealthChecks.register(qInstance, qBitConfig);
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
return (output);
}
@@ -375,7 +375,11 @@ public final class CrmAction
}
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
{
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
if("emailTemplateId".equals(field.getName()) && transport != null && transport.usesSharedTemplates())
{
transport.templateVariableNames(input.getValueInteger("emailTemplateId"));
}
else if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
{
return (field.getLabel() + " is unavailable");
}
@@ -21,8 +21,11 @@
package com.kingsrook.qbits.crm.email;
import java.io.Serializable;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
@@ -123,6 +126,58 @@ public class CrmEmailTransport
return (new Delivery(null, CrmEmailStatus.SENT, Instant.now(), null, null, null, null));
}
/*******************************************************************************
** Shared storage/rendering is explicitly enabled only after host migration.
*******************************************************************************/
public boolean usesSharedTemplates()
{
return (config.getSharedEmailTemplates());
}
/*******************************************************************************
** Only declared inputs are computed and passed to the email-owned renderer.
*******************************************************************************/
public Set<String> templateVariableNames(Integer templateId) throws QException
{
return (EmailApi.templateVariableNames(templateId));
}
/*******************************************************************************
** Keep optional email API types behind this adapter boundary.
*******************************************************************************/
public record TemplateDelivery(String subject, Delivery delivery)
{
}
/*******************************************************************************
** Checked selection, then native outbox rendering retains all send policies.
*******************************************************************************/
public TemplateDelivery sendTemplateMessage(String purpose, String id, Integer contactId, String address, Integer templateId,
String subject, String body, boolean marketing, Map<String, Serializable> variables) throws QException
{
if(!emailInstalled())
{
throw (new QUserFacingException("Shared CRM templates require qbit-email"));
}
String key = "crm:" + purpose + ":" + id;
EmailApiRequest request = new EmailApiRequest().withTo(address)
.withCategory(marketing ? EmailApiCategory.MARKETING : EmailApiCategory.TRANSACTIONAL)
.withSubject(subject == null || subject.isBlank() ? null : subject)
.withMarkdownBody(body == null || body.isBlank() ? null : body)
.withLinkedRecord("crm_contact", contactId).withSender("crm", purpose).withIdempotencyKey(key);
variables.forEach(request::withVariable);
var checked = EmailApi.sendTemplateOnce(templateId, request, key);
var result = checked.effect();
if(result.outcome() == EmailApiEffectOutcome.REFUSED)
{
throw (new QUserFacingException("The email service could not accept this template message."));
}
var message = result.message();
Delivery delivery = message == null ? new Delivery(null, CrmEmailStatus.QUEUED, null, null, null, null, null)
: new Delivery(String.valueOf(message.id()), status(message.status()), message.sentDate(), message.deliveredDate(), null, null, null);
return (new TemplateDelivery(checked.subject(), delivery));
}
/*******************************************************************************
** CRM-owned delivery snapshot; optional email types never escape this adapter.
*******************************************************************************/
@@ -21,8 +21,12 @@
package com.kingsrook.qbits.crm.email;
import java.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.crm.api.ActivityKind;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
@@ -81,28 +85,43 @@ public final class CrmEmails
recordActivity(existing.getFirst());
return;
}
if(templateId != null)
CrmEmailTransport.Delivery delivery;
if(templateId != null && transport.usesSharedTemplates())
{
QRecord template = GetAction.execute("crm_email_template", templateId);
if(template == null)
{
throw (new QUserFacingException("Email template is unavailable"));
}
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
if(body == null)
{
body = template.getValueString("bodyHtml");
}
Set<String> names = new HashSet<>(transport.templateVariableNames(templateId));
String overrides = String.valueOf(subject) + body;
Set.of("contact.firstName", "contact.lastName", "company.name", "owner.name", "unsubscribeUrl").stream()
.filter(name -> overrides.contains("{{" + name + "}}")).forEach(names::add);
var sent = transport.sendTemplateMessage(purpose, id, contact.getValueInteger("id"), contact.getValueString("email"),
templateId, subject, body, enrollmentId != null, templateVariables(contact, names));
subject = sent.subject();
delivery = sent.delivery();
}
if(subject == null || subject.isBlank() || body == null || body.isBlank())
else
{
throw (new QUserFacingException("Email subject and body are required"));
if(templateId != null)
{
QRecord template = GetAction.execute("crm_email_template", templateId);
if(template == null)
{
throw (new QUserFacingException("Email template is unavailable"));
}
subject = subject == null || subject.isBlank() ? template.getValueString("subject") : subject;
body = body == null || body.isBlank() ? template.getValueString("bodyText") : body;
if(body == null)
{
body = template.getValueString("bodyHtml");
}
}
if(subject == null || subject.isBlank() || body == null || body.isBlank())
{
throw (new QUserFacingException("Email subject and body are required"));
}
subject = render(subject, contact);
body = render(body, contact);
delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
contact.getValueString("email"), subject, body, enrollmentId != null);
}
subject = render(subject, contact);
body = render(body, contact);
CrmEmailTransport.Delivery delivery = transport.sendMessage(purpose, id, contact.getValueInteger("id"),
contact.getValueString("email"), subject, body, enrollmentId != null);
QRecord message = CrmRecords.insert("crm_email_message", new QRecord().withValue("contactId", contact.getValue("id"))
.withValue("emailTemplateId", templateId).withValue("sequenceEnrollmentId", enrollmentId)
.withValue("toAddresses", contact.getValue("email")).withValue("subject", subject)
@@ -136,6 +155,29 @@ public final class CrmEmails
.withValue("activityId", activities.getFirst().getValue("id")));
}
/*******************************************************************************
** Values come from the caller's visible CRM records, never a system query.
*******************************************************************************/
private static Map<String, Serializable> templateVariables(QRecord contact, Set<String> names) throws QException
{
QRecord company = !names.contains("company.name") && !names.contains("company") || contact.getValue("companyId") == null
? null : GetAction.execute("crm_company", contact.getValue("companyId"));
Map<String, Serializable> available = new LinkedHashMap<>();
available.put("contact.firstName", value(contact, "firstName"));
available.put("contact.lastName", value(contact, "lastName"));
available.put("company.name", value(company, "name"));
available.put("owner.name", value(contact, "ownerUserId"));
available.put("contact", new LinkedHashMap<>(Map.of("firstName", value(contact, "firstName"), "lastName", value(contact, "lastName"))));
available.put("company", new LinkedHashMap<>(Map.of("name", value(company, "name"))));
available.put("owner", new LinkedHashMap<>(Map.of("name", value(contact, "ownerUserId"))));
if(names.contains("unsubscribeUrl"))
{
available.put("unsubscribeUrl", new CrmMarketingApi().buildUnsubscribeUrl(contact.getValueInteger("id"), null));
}
available.keySet().retainAll(names);
return (available);
}
/*******************************************************************************
** Literal replacements avoid treating customer data as a template program.
*******************************************************************************/
@@ -35,6 +35,11 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
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.QFilterOrderBy;
@@ -50,8 +55,7 @@ import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)
** CRM checks for hosts to register with their health middleware. Run in the
** Core health checks with retained legacy HealthIndicator adapters. Run in the
** caller's tenant context when one is supplied, else as the core system user;
** no check returns personal values.
*******************************************************************************/
@@ -78,6 +82,36 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Register fixed core definitions without database reads or captured user sessions.
** Legacy indicator names stay unchanged for existing middleware callers.
*******************************************************************************/
public static void register(QInstance instance, CrmQBitConfig config)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
CrmHealthChecks checks = new CrmHealthChecks(config);
Map<String, String> names = Map.of("crm.email", "crm.email", "crm.sequence-lag", "crm.sequenceLag", "crm.lead-score", "crm.leadScore");
names.forEach((name, legacyName) ->
{
if(registry.getChecks().stream().noneMatch(check -> name.equals(check.getName())))
{
registry.withCheck(new HealthCheckMetaData().withName(name).withLabel(name).withComponent("crm")
.withCategory(HealthCategory.INTEGRATION).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(300).withTimeoutMs(5000).withCheckInstance(input ->
{
if(input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline()))
{
return (HealthCheckOutcome.unknown("CRM check deadline exceeded"));
}
HealthCheckResult result = checks.new Indicator(legacyName, QContext.getQSession()).check(input.getQInstance());
return (new HealthCheckOutcome().withStatus(com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(result.getStatus().name()))
.withMessage("CRM health check " + result.getStatus().name()));
}));
}
});
}
/*******************************************************************************
** Run all checks without registering tables or starting background workers.
*******************************************************************************/
@@ -76,6 +76,11 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
{
widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class));
}
if("crm_setup".equals(name))
{
widget.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.MANAGE_BASE))
.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmSetupVisibility.class));
}
return (widget);
}
}
@@ -23,8 +23,6 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -89,6 +87,16 @@ public final class CrmSetupStatus
/*******************************************************************************
** True when any business setup step still needs attention.
*******************************************************************************/
public static boolean isIncomplete() throws QException
{
return (load().stream().anyMatch(item -> item.state() == State.TODO));
}
/*******************************************************************************
** Build the items for the current user.
*******************************************************************************/
@@ -160,14 +168,6 @@ public final class CrmSetupStatus
? new Item("Base currency", "crm_currency", State.TODO, "None: amounts have no currency prefix and conversions do nothing.", "Add your currency with Base currency ticked and rate 1.")
: new Item("Base currency", "crm_currency", State.OK, base.getFirst().getValueString("currencyCode") + " is the base currency.", null));
///////////////////////////////////////////////////////
// email: sequences and list mail need a delivery path //
///////////////////////////////////////////////////////
CrmReadiness readiness = CrmApi.readiness();
items.add(readiness == CrmReadiness.NOT_CONFIGURED
? new Item("Email delivery", "crm_email_template", State.OPTIONAL, "No email path: sequences and list email cannot send.",
"Install and configure the email qbit, or set a messaging provider on the CRM configuration.")
: new Item("Email delivery", "crm_email_template", State.OK, readiness == CrmReadiness.QBIT_EMAIL ? "Sends through the email qbit." : "Sends through core messaging.", null));
return (items);
}
@@ -0,0 +1,64 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.crm.widgets;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Show the Setup card only to CRM settings holders (crm.manage) and only while
** a business setup step is still missing. Evaluated at request time, so a
** completed setup drops the card from per-session metadata and from render.
*******************************************************************************/
public class CrmSetupVisibility implements CustomPermissionChecker
{
/*******************************************************************************
** Require crm.hasAccess and crm.manage.hasAccess, then an incomplete setup.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS) || !QContext.getQSession().hasPermission(CrmPermissions.MANAGE))
{
throw (new QPermissionDeniedException("CRM settings access is required."));
}
try
{
if(CrmSetupStatus.isIncomplete())
{
return;
}
}
catch(QException e)
{
throw (new QPermissionDeniedException("Setup status is not available.", e));
}
throw (new QPermissionDeniedException("CRM setup is complete."));
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ entries:
- key: app:crm;slot:help
summary: "Manage relationships, sales opportunities and consent."
content: |
Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. The Setup card on this page shows what still needs configuring: the default pipeline and its stages, lifecycle stages, the base currency and email delivery. Ask your administrator which reference data and base currency are configured for your installation. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport.
Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. The Setup card on this page shows what still needs configuring: the default pipeline and its stages, lifecycle stages, and the base currency. It only appears for CRM settings users, and only while a step is missing. Ask your administrator which reference data and base currency are configured for your installation. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport.
- key: table:crm_job_state;slot:help
summary: "Internal maintenance checkpoints."
content: |
+1
View File
@@ -0,0 +1 @@
version=${project.version}
@@ -220,4 +220,50 @@ class CrmHealthBulkLoadTest extends BaseTest
assertThat(status).isEqualTo(HealthStatus.UP);
}
/*******************************************************************************
** Core registration is idempotent, preserves tenant visibility and legacy results.
*******************************************************************************/
@Test
void coreRegistrationPreservesCallerAndWorkerContexts() throws Exception
{
var instance = QContext.getQInstance();
var session = QContext.getQSession();
CrmHealthChecks.register(instance, new CrmQBitConfig());
var registry = com.kingsrook.qqq.backend.core.health.HealthChecksMetaData.ofOrWithNew(instance);
assertThat(registry.getChecks()).extracting(check -> check.getName())
.containsExactlyInAnyOrder("crm.email", "crm.sequence-lag", "crm.lead-score");
var check = registry.getChecks().stream().filter(value -> value.getName().equals("crm.sequence-lag")).findFirst().orElseThrow();
P4TestSupport.seed("crm_sequence_enrollment", new QRecord().withValue("id", 9).withValue("contactId", 2)
.withValue("status", CrmEnrollmentStatus.ACTIVE.getId()).withValue("nextStepDate", Instant.now().minus(2, ChronoUnit.HOURS)));
instance.addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("healthTenant"));
instance.getTable("crm_sequence_enrollment").withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock()
.withFieldName("contactId").withSecurityKeyType("healthTenant"));
session.withSecurityKeyValue("healthTenant", 1);
var input = new com.kingsrook.qqq.backend.core.health.HealthCheckInput().withQInstance(instance);
assertThat(check.getCheckInstance().run(input).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UP);
assertThat(QContext.getQSession()).isSameAs(session);
session.withSecurityKeyValue("healthTenant", 2);
assertThat(check.getCheckInstance().run(input).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.DEGRADED);
assertThat(new CrmHealthChecks(new CrmQBitConfig()).indicators(session).get(1).check(instance).getStatus()).isEqualTo(HealthStatus.DEGRADED);
var worker = java.util.concurrent.CompletableFuture.supplyAsync(() ->
{
try
{
var result = check.getCheckInstance().run(input);
var legacy = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1).check(instance);
assertThat(result.getStatus().name()).isEqualTo(legacy.getStatus().name());
assertThat(QContext.getQInstance()).isNull();
assertThat(QContext.getQSession()).isNull();
return (result.getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
}).get();
// A system session authenticates the worker but does not invent tenant security keys.
assertThat(worker).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UP);
assertThat(check.getCheckInstance().run(input.withDeadline(Instant.EPOCH)).getStatus()).isEqualTo(com.kingsrook.qqq.backend.core.health.HealthStatus.UNKNOWN);
}
}
@@ -110,7 +110,7 @@ class CrmPublicationHelpTest extends BaseTest
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-crm");
assertThat(bundle.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
});
HelpBundles.syncOnStartup(instance);
@@ -62,7 +62,7 @@ class CrmQBitProducerTest extends BaseTest
assertThat(metaData).isNotNull();
assertThat(metaData.getGroupId()).isEqualTo(CrmQBitProducer.GROUP_ID);
assertThat(metaData.getArtifactId()).isEqualTo(CrmQBitProducer.ARTIFACT_ID);
assertThat(metaData.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
}
@@ -114,7 +114,7 @@ class CrmQBitProducerTest extends BaseTest
{
assertThat(CrmQBitProducer.GROUP_ID).isEqualTo("com.kingsrook.qbits");
assertThat(CrmQBitProducer.ARTIFACT_ID).isEqualTo("crm");
assertThat(CrmQBitProducer.VERSION).isEqualTo("0.2.0-SNAPSHOT");
assertThat(CrmQBitProducer.VERSION).isEqualTo(System.getProperty("qbit.artifactVersion"));
}
/*******************************************************************************
@@ -0,0 +1,92 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.
*/
/*******************************************************************************
** Unit tests for CrmQBitProducer.
*******************************************************************************/
package com.kingsrook.qbits.crm;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
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.modules.backend.implementations.memory.MemoryBackendModule;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Migration opt-in removes metadata completely without changing legacy hosts.
*******************************************************************************/
class SharedEmailTemplateMetadataTest extends BaseTest
{
/*******************************************************************************
** Existing hosts keep their actual legacy table, source and configuration.
*******************************************************************************/
@Test
void legacyDefaultRemains()
{
assertThat(new CrmQBitConfig().getSharedEmailTemplates()).isFalse();
assertThat(QContext.getQInstance().getTable("crm_email_template")).isNotNull();
assertThat(QContext.getQInstance().getPossibleValueSource("crm_email_template")).isNotNull();
}
/*******************************************************************************
** The opt-in host has one owner and no retired-table or dummy-source metadata.
*******************************************************************************/
@Test
void sharedOwnerMetadataValidates() throws Exception
{
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withTemplatePermissionBaseName("crm.manage")).produce(instance);
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables("memory").withSharedEmailTemplates(true))
.produce(instance).addSelfToInstance(instance);
new QInstanceValidator().validate(instance);
assertThat(instance.getTable("crm_email_template")).isNull();
assertThat(instance.getPossibleValueSource("crm_email_template")).isNull();
assertThat(instance.getTable("email_template")).isNotNull();
assertThat(instance.getTable("crm_sequence_step").getField("emailTemplateId").getPossibleValueSourceName()).isEqualTo("email_template");
assertThat(instance.getTable("crm_email_message").getField("emailTemplateId").getPossibleValueSourceName()).isEqualTo("email_template");
assertThat(instance.getProcess("crm_send_email").getInputField("emailTemplateId").orElseThrow().getPossibleValueSourceName()).isEqualTo("email_template");
assertThat(instance.getApp("crm").getSections()).allSatisfy(section -> assertThat(section.getTables()).doesNotContain("crm_email_template"));
}
/*******************************************************************************
** Unsupported tenant opt-in fails before changing existing metadata.
*******************************************************************************/
@Test
void optInRejectsMissingOwnerAndTenancy()
{
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withSharedEmailTemplates(true))
.produce(new QInstance())).hasMessageContaining("installed qbit-email");
assertThatThrownBy(() -> new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withSharedEmailTemplates(true).withTenantSecurityKeyType("tenant"))
.produce(new QInstance())).hasMessageContaining("non-tenant");
}
}
@@ -162,6 +162,32 @@ class CrmActionsTest extends BaseTest
assertThat(QContext.getQInstance().getProcess("crm_move_deal_stage").getInputField("targetStageId").orElseThrow().getPossibleValueSourceFilter()).isNotNull();
}
/*******************************************************************************
** Runtime Review uses the shared owner even though action field prototypes are legacy.
*******************************************************************************/
@Test
void sharedTemplateReviewDoesNotReadRetiredTable() throws Exception
{
seed(CrmActions.action("crm_send_email"));
var instance = QContext.getQInstance();
new com.kingsrook.qbits.email.EmailQBitProducer().withQBitConfig(new com.kingsrook.qbits.email.EmailQBitConfig()
.withBackendName(BACKEND_NAME).withTemplatePermissionBaseName("crm.manage")).produce(instance);
new com.kingsrook.qqq.backend.core.instances.QInstanceValidator().validate(instance);
var grants = new java.util.HashSet<>(CrmPermissions.ALL);
grants.addAll(com.kingsrook.qbits.email.permissions.EmailPermissions.ALL);
QContext.getQSession().setPermissions(grants);
com.kingsrook.qbits.crm.api.CrmApi.config().withSharedEmailTemplates(true);
com.kingsrook.qbits.email.api.EmailApi.ensureTemplate(new com.kingsrook.qbits.email.api.EmailApiTemplate().withName("shared_review").withLabel("Shared")
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(com.kingsrook.qbits.email.api.EmailApiCategory.TRANSACTIONAL).withSubject("Hi").withBody("Body"));
Integer template = rows("email_template").getFirst().getValueInteger("id");
instance.getTables().remove("crm_email_template");
instance.getProcess("crm_send_email").getInputField("emailTemplateId").orElseThrow().setPossibleValueSourceName("email_template");
var values = inputs();
values.put("emailTemplateId", template);
var result = review("crm_send_email", values, List.of(1));
assertThat(lines(result, false)).isNotEmpty().allMatch(line -> line.getStatus() != Status.ERROR);
}
/*******************************************************************************
** Administrative and management actions have distinct permission grants.
*******************************************************************************/
@@ -20,13 +20,28 @@
package com.kingsrook.qbits.crm.email;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
@@ -59,6 +74,33 @@ public class CrmEmailTransportTest extends BaseTest
RecordingProvider.last = null;
}
/*******************************************************************************
** Shared mode selects the email-owned template and records the final subject.
*******************************************************************************/
@Test
void sharedTemplateUsesOwnerAndExactSubject() throws Exception
{
var instance = QContext.getQInstance();
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig()
.withBackendName(BACKEND_NAME).withTemplatePermissionBaseName("crm.manage")).produce(instance);
new QInstanceValidator().validate(instance);
Set<String> permissions = new HashSet<>(CrmPermissions.ALL);
permissions.addAll(EmailPermissions.ALL);
QContext.getQSession().setPermissions(permissions);
new InsertAction().execute(new InsertInput("email_provider")
.withRecord(new QRecord().withValue("name", "test").withValue("label", "Test").withValue("kind", "SMTP")
.withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE").withValue("fromAddress", "sender@example.test").withValue("enabled", true).withValue("isDefault", true)));
EmailApi.ensureTemplate(new EmailApiTemplate().withName("shared")
.withLabel("Shared").withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("Message {{message.id}}").withBody("<p>Native HTML</p>"));
var templates = new QueryAction().execute(new QueryInput("email_template")).getRecords();
var transport = new CrmEmailTransport(CrmApi.config().withSharedEmailTemplates(true));
var first = transport.sendTemplateMessage("manual", "42", 1, "recipient@example.test", templates.getFirst().getValueInteger("id"), null, null, false, Map.of());
assertThat(first.subject()).isEqualTo("Message " + first.delivery().messageId());
var repeated = transport.sendTemplateMessage("manual", "42", 1, "recipient@example.test", templates.getFirst().getValueInteger("id"), null, null, false, Map.of());
assertThat(repeated).isEqualTo(first);
}
/*******************************************************************************
** Public constructor allows the core dispatcher to instantiate this provider.
*******************************************************************************/
@@ -23,12 +23,21 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -38,19 +47,18 @@ import static org.assertj.core.api.Assertions.assertThat;
class CrmSetupStatusTest extends BaseTest
{
/*******************************************************************************
** Nothing configured: the four things the CRM needs are flagged.
** Nothing configured: the business setup the CRM needs are flagged.
*******************************************************************************/
@Test
void emptyInstallNeedsSetup() throws Exception
{
Map<String, CrmSetupStatus.Item> items = items();
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency", "Email delivery");
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency");
assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails");
assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity");
assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Email delivery").state()).isNotEqualTo(CrmSetupStatus.State.TODO);
}
@@ -106,7 +114,7 @@ class CrmSetupStatusTest extends BaseTest
{
assertThat(CrmHomeWidgets.HOME).contains("crm_setup");
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows()).hasSize(5);
assertThat(data.getRows()).hasSize(4);
CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline");
assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup");
@@ -114,6 +122,76 @@ class CrmSetupStatusTest extends BaseTest
/*******************************************************************************
** The checklist is business setup only: no email-provider row.
*******************************************************************************/
@Test
void noEmailProviderRow() throws Exception
{
assertThat(items().keySet()).noneMatch(label -> label.toLowerCase().contains("email"));
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows().toString()).doesNotContain("email qbit").doesNotContain("Email delivery");
}
/*******************************************************************************
** Settings holders get the card while setup is incomplete, and not once complete.
*******************************************************************************/
@Test
void settingsHolderSeesCardOnlyWhileIncomplete() throws Exception
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isTrue();
completeSetup();
assertThat(CrmSetupStatus.isIncomplete()).isFalse();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** A CRM user without the settings permission never gets the card, incomplete or not.
*******************************************************************************/
@Test
void nonSettingsUserNeverSeesCard() throws Exception
{
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE));
assertThat(CrmSetupStatus.isIncomplete()).isTrue();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Render the setup widget the way the UI API does, with permissions enforced.
*******************************************************************************/
private void renderEnforced() throws Exception
{
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_setup"));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new RenderWidgetAction().execute(input);
}
/*******************************************************************************
** Seed everything the checklist asks for.
*******************************************************************************/
private void completeSetup() throws Exception
{
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
}
/*******************************************************************************
**
*******************************************************************************/