Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e361a9d133
|
||
|
|
a8e9a34b51 | ||
|
|
26b8847b46
|
||
|
|
820474805d | ||
|
|
5157f81208
|
||
|
|
c6031c26ed | ||
|
|
08e880af2e
|
||
|
|
e3f20d357e |
No files matched your search
@@ -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 }}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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: |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user