Compare commits

..
Author SHA1 Message Date
james.maesandCodex d2c2f8a9b8 ci: centralize QBit source scanning
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:20:46 -05:00
james.maesandCodex e422092fd4 ci: adopt central orb 0.7.12 and stable gates (#28)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:07 -05:00
james.maesandCodex 70f841c256 ci: centralize rc release completion in qqq orb (#27)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:01 -05:00
james.maesandCodex 730bb68428 ci: adopt shared orb 0.7.11 (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:56:34 -05:00
james.maes 62c393374d Merge pull request #25 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:20:28 -05:00
james.maesandCodex c380dce8b6 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:42 -05:00
18 changed files with 139 additions and 496 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -14
View File
@@ -1,26 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
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
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
-3
View File
@@ -2,9 +2,6 @@
## 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.
-13
View File
@@ -1,18 +1,5 @@
# 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,
+3 -4
View File
@@ -23,7 +23,6 @@
<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>
@@ -88,7 +87,7 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<version>0.1.0-RC.3</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -233,14 +232,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.20 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 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.20</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -67,15 +67,14 @@ 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)));
.withTables(new ArrayList<>(SETTINGS_TABLES)));
List<String> tables = new ArrayList<>(MAIN_TABLES);
tables.addAll(settings);
tables.addAll(SETTINGS_TABLES);
for(String name : tables)
{
app.withChild(output.get(QTableMetaData.class, name));
@@ -41,7 +41,6 @@ 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;
@@ -364,22 +363,4 @@ 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);
}
}
@@ -72,7 +72,6 @@ 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;
@@ -392,28 +391,10 @@ 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);
}
@@ -435,7 +416,6 @@ 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,11 +375,7 @@ public final class CrmAction
}
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
{
if("emailTemplateId".equals(field.getName()) && transport != null && transport.usesSharedTemplates())
{
transport.templateVariableNames(input.getValueInteger("emailTemplateId"));
}
else if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
{
return (field.getLabel() + " is unavailable");
}
@@ -21,11 +21,8 @@
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;
@@ -126,58 +123,6 @@ 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,12 +21,8 @@
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;
@@ -85,43 +81,28 @@ public final class CrmEmails
recordActivity(existing.getFirst());
return;
}
CrmEmailTransport.Delivery delivery;
if(templateId != null && transport.usesSharedTemplates())
if(templateId != null)
{
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();
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");
}
}
else
if(subject == null || subject.isBlank() || body == null || body.isBlank())
{
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);
throw (new QUserFacingException("Email subject and body are required"));
}
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)
@@ -155,29 +136,6 @@ 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,11 +35,6 @@ 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;
@@ -55,7 +50,8 @@ import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
/*******************************************************************************
** Core health checks with retained legacy HealthIndicator adapters. Run in the
** 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
** caller's tenant context when one is supplied, else as the core system user;
** no check returns personal values.
*******************************************************************************/
@@ -82,36 +78,6 @@ 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.
*******************************************************************************/
@@ -220,50 +220,4 @@ 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);
}
}
@@ -1,92 +0,0 @@
/*
* 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,32 +162,6 @@ 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,28 +20,13 @@
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;
@@ -74,33 +59,6 @@ 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.
*******************************************************************************/