diff --git a/CHANGELOG.md b/CHANGELOG.md index fa157a9..3c38833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 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. diff --git a/UPGRADE.md b/UPGRADE.md index 6d03e74..1bf34b7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -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, diff --git a/pom.xml b/pom.xml index 0300453..ea68836 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,7 @@ 0.2.0-SNAPSHOT + 0.1.0-RC.10 21 21 0.8.15 @@ -87,7 +88,7 @@ com.kingsrook.qbits qbit-email - 0.1.0-RC.3 + ${qbit-email.version} true @@ -232,14 +233,14 @@ - + qqq-snapshot true - 4.1.0-RC.7 + 4.1.0-RC.20 diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java b/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java index d6b0227..5d474be 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java @@ -67,14 +67,15 @@ public final class CrmAppMetaData *******************************************************************************/ public static QAppMetaData build(MetaDataProducerMultiOutput output, String source) { + List 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 tables = new ArrayList<>(MAIN_TABLES); - tables.addAll(SETTINGS_TABLES); + tables.addAll(settings); for(String name : tables) { app.withChild(output.get(QTableMetaData.class, name)); diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java index adb75e4..27cf588 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java @@ -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); + } + + } diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index 0d07c9c..3deae57 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -72,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; @@ -391,10 +392,28 @@ public class CrmQBitProducer implements QBitMetaDataProducer @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); } @@ -416,6 +435,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer 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); } diff --git a/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java b/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java index 9b2cf9f..5d63817 100644 --- a/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java +++ b/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java @@ -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"); } diff --git a/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java b/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java index 3dd8bbf..41394e6 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/CrmEmailTransport.java @@ -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 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 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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/email/CrmEmails.java b/src/main/java/com/kingsrook/qbits/crm/email/CrmEmails.java index 802714a..c8c9290 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/CrmEmails.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/CrmEmails.java @@ -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 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 templateVariables(QRecord contact, Set 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 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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java index 16bf3b6..8662a1d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java +++ b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java @@ -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 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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java index 91819fb..cc8077c 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java @@ -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); + } + } diff --git a/src/test/java/com/kingsrook/qbits/crm/SharedEmailTemplateMetadataTest.java b/src/test/java/com/kingsrook/qbits/crm/SharedEmailTemplateMetadataTest.java new file mode 100644 index 0000000..0677551 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/SharedEmailTemplateMetadataTest.java @@ -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"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java b/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java index 7c21740..d667525 100644 --- a/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java b/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java index 7d38c0f..db81c21 100644 --- a/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/email/CrmEmailTransportTest.java @@ -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 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("

Native HTML

")); + 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. *******************************************************************************/