Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e361a9d133
|
No files matched your search
+57
-11
@@ -1,14 +1,60 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
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
|
||||
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
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+$/]
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,74 +1,26 @@
|
||||
# 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: Plan a cut without changing release state
|
||||
description: Print the plan without merging or creating branches
|
||||
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
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
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 || 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 || '' }}
|
||||
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 }}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
+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>
|
||||
@@ -232,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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
@@ -416,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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user