Compare commits

..
21 changed files with 1834 additions and 2725 deletions

No files matched your search

+57 -11
View File
@@ -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+$/]
-23
View File
@@ -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
+13 -62
View File
@@ -1,74 +1,25 @@
# 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
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
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 || '' }}
secrets: inherit
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
-16
View File
@@ -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
-4
View File
@@ -2,10 +2,6 @@
## Unreleased / 0.2.0
- Read the CRM QBit metadata version from the Maven-filtered artifact version so release metadata matches the build.
- Adopt QQQ 4.1.0-RC.7 native help bundles, replacing the RC.6 stand-in loader with host-neutral CRM help, live references and the core coverage gate.
- Upgrade to Java 21, QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.2.
- Add Maven wrapper, CI configuration, architecture guardrails and coverage gates.
- Rename tables, joins, widgets and CRM processes to `crm_` snake_case, with inferred snake_case SQL columns.
-18
View File
@@ -174,24 +174,6 @@ Later phases complete the following behavior:
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase.
## In-app help
CRM registers [help/qbit-crm.yaml](src/main/resources/help/qbit-crm.yaml) from
`CrmQBitProducer`. The bundle uses the `QBIT` layer and the current artifact version.
Registration only adds metadata and works without a `helpContent` table; QQQ's
startup sync skips hosts without that table. Hosts with help storage load help
and call `HelpBundles.syncOnStartup(qInstance)` after defining their metadata.
To customize wording or describe your deployment, register a separate `HOST`
bundle with the same entry keys (and roles). HOST text takes precedence over QBIT
text; the sync preserves user edits for review. Keep live `{{...}}` references
when referring to labels, defaults or enum values.
`CrmPublicationHelpTest` runs `HelpBundleAssertions` against the produced and
validated CRM instance, including generated record actions, and also requires
widget and possible-value help. `CrmTablesGuardrailTest` checks every table's
row-creation guidance. Run both through `./mvnw -q verify`.
## Source layout
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
+3 -31
View File
@@ -22,7 +22,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.2.0-SNAPSHOT</revision>
<revision>0.2.0-RC.3</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -135,35 +135,7 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-crm.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-crm.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -232,14 +204,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.6 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.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -26,12 +26,9 @@
package com.kingsrook.qbits.crm;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.customizers.CurrencyRateDateCustomizer;
@@ -46,6 +43,7 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealProduct;
import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
import com.kingsrook.qbits.crm.security.CrmPermissions;
@@ -68,7 +66,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
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;
@@ -89,40 +86,12 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "crm";
public static final String VERSION = loadVersion();
public static final String VERSION = "0.2.0-SNAPSHOT";
private CrmQBitConfig qBitConfig;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CrmQBitProducer.class.getClassLoader().getResourceAsStream("qbit-crm.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-crm.properties; build with Maven to provide the artifact version");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-crm.properties; build with Maven to provide the artifact version");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read artifact version from qbit-crm.properties", e);
}
}
/*******************************************************************************
** Produce the QBitMetaData for the CRM QBit.
*******************************************************************************/
@@ -415,8 +384,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.
HelpBundles.register(qInstance, "help/qbit-crm.yaml");
CrmHelpBundleStandIn.apply(output);
return (output);
}
@@ -43,7 +43,6 @@ 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.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.middleware.health.HealthIndicator;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
@@ -52,8 +51,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
** caller's tenant context when one is supplied, else as the core system user;
** no check returns personal values.
** caller's tenant context; no check elevates access or returns personal values.
*******************************************************************************/
public class CrmHealthChecks
{
@@ -88,21 +86,17 @@ public class CrmHealthChecks
/*******************************************************************************
** Expose individual checks through the RC.6 health SPI. With no caller
** session (registration at startup), each check runs as the core system
** user: token-based authentication modules (OAuth2, Auth0) reject any other
** tokenless session, and the checks report only UP/DEGRADED, never values.
** Expose individual checks through the RC.6 health SPI.
*******************************************************************************/
public List<HealthIndicator> indicators()
{
return (indicators(QContext.getQSession()));
return (indicators(QContext.getQSession() == null ? new QSession() : QContext.getQSession()));
}
/*******************************************************************************
** Supply the host's monitoring session explicitly for middleware worker threads.
** Its permissions and tenant keys are preserved; this adapter never grants keys.
** A null session runs each check as the core system user.
*******************************************************************************/
public List<HealthIndicator> indicators(QSession session)
{
@@ -142,7 +136,7 @@ public class CrmHealthChecks
CapturedContext original = QContext.capture();
try
{
QContext.init(instance, session != null ? session : new QSystemUserSession(instance));
QContext.init(instance, session);
return (switch(name)
{
case "crm.email" -> email();
@@ -0,0 +1,135 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.help;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)
** Load the future bundle format with core's Jackson YAML parser. RC.6 lacks
** process/PVS help slots: action content is applied to frontend steps instead;
** enum entries remain in the bundle for the central loader.
*******************************************************************************/
public final class CrmHelpBundleStandIn
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmHelpBundleStandIn()
{
}
/*******************************************************************************
** Validate the resource eagerly so malformed shipped help fails production.
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = CrmHelpBundleStandIn.class.getResourceAsStream("/help/qbit-crm.yaml"))
{
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-crm".equals(bundle.path("bundle").asText()) || bundle.path("version").asInt() != 1
|| !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("Invalid CRM bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
if(key.isBlank() || entry.path("summary").asText().isBlank() || entry.path("summary").asText().length() > 120
|| entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("Invalid or duplicate help entry: " + key));
}
}
return (entries);
}
catch(IOException | IllegalArgumentException e)
{
throw (new QException("Could not load help/qbit-crm.yaml", e));
}
}
/*******************************************************************************
** Apply help to the objects produced by this QBit only.
*******************************************************************************/
public static void apply(MetaDataProducerMultiOutput output) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : output.getEach(QTableMetaData.class))
{
table.withHelpContent("help", content(entries, "table:" + table.getName() + ";slot:help"));
for(var field : table.getFields().values())
{
String key = "table:" + table.getName() + ";field:" + field.getName();
if(entries.containsKey(key))
{
field.withHelpContent(content(entries, key));
}
}
}
for(QProcessMetaData process : output.getEach(QProcessMetaData.class))
{
QHelpContent help = content(entries, "process:" + process.getName() + ";slot:help");
for(var step : process.getStepList())
{
if(step instanceof QFrontendStepMetaData frontend)
{
frontend.withHelpContent(help.clone());
}
}
}
for(QAppMetaData app : output.getEach(QAppMetaData.class))
{
app.withHelpContent("help", content(entries, "app:" + app.getName() + ";slot:help"));
}
}
/*******************************************************************************
** RC.6 has no summary property, so retain the summary in the bundle only.
*******************************************************************************/
private static QHelpContent content(Map<String, JsonNode> entries, String key) throws QException
{
if(!entries.containsKey(key))
{
throw (new QException("Missing CRM help entry: " + key));
}
return (new QHelpContent().withContentAsMarkdown(entries.get(key).get("content").asText()));
}
}
@@ -76,11 +76,6 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
{
widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class));
}
if("crm_setup".equals(name))
{
widget.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.MANAGE_BASE))
.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmSetupVisibility.class));
}
return (widget);
}
}
@@ -23,6 +23,8 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -87,16 +89,6 @@ public final class CrmSetupStatus
/*******************************************************************************
** True when any business setup step still needs attention.
*******************************************************************************/
public static boolean isIncomplete() throws QException
{
return (load().stream().anyMatch(item -> item.state() == State.TODO));
}
/*******************************************************************************
** Build the items for the current user.
*******************************************************************************/
@@ -168,6 +160,14 @@ public final class CrmSetupStatus
? new Item("Base currency", "crm_currency", State.TODO, "None: amounts have no currency prefix and conversions do nothing.", "Add your currency with Base currency ticked and rate 1.")
: new Item("Base currency", "crm_currency", State.OK, base.getFirst().getValueString("currencyCode") + " is the base currency.", null));
///////////////////////////////////////////////////////
// email: sequences and list mail need a delivery path //
///////////////////////////////////////////////////////
CrmReadiness readiness = CrmApi.readiness();
items.add(readiness == CrmReadiness.NOT_CONFIGURED
? new Item("Email delivery", "crm_email_template", State.OPTIONAL, "No email path: sequences and list email cannot send.",
"Install and configure the email qbit, or set a messaging provider on the CRM configuration.")
: new Item("Email delivery", "crm_email_template", State.OK, readiness == CrmReadiness.QBIT_EMAIL ? "Sends through the email qbit." : "Sends through core messaging.", null));
return (items);
}
@@ -1,64 +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.
*/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Show the Setup card only to CRM settings holders (crm.manage) and only while
** a business setup step is still missing. Evaluated at request time, so a
** completed setup drops the card from per-session metadata and from render.
*******************************************************************************/
public class CrmSetupVisibility implements CustomPermissionChecker
{
/*******************************************************************************
** Require crm.hasAccess and crm.manage.hasAccess, then an incomplete setup.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS) || !QContext.getQSession().hasPermission(CrmPermissions.MANAGE))
{
throw (new QPermissionDeniedException("CRM settings access is required."));
}
try
{
if(CrmSetupStatus.isIncomplete())
{
return;
}
}
catch(QException e)
{
throw (new QPermissionDeniedException("Setup status is not available.", e));
}
throw (new QPermissionDeniedException("CRM setup is complete."));
}
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
version=${project.version}
@@ -35,9 +35,6 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
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.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
@@ -189,35 +186,4 @@ class CrmHealthBulkLoadTest extends BaseTest
assertThat(QContext.getQInstance()).isSameAs(instance);
}
/*******************************************************************************
** Regression: indicators registered with no caller session (at host startup)
** must still run on a host whose authentication rejects tokenless sessions
** (OAuth2, Auth0). They used a plain new QSession(), so every check failed
** session validation and reported UNKNOWN.
*******************************************************************************/
@Test
void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception
{
var instance = QContext.getQInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("token-required")
.withType(QAuthenticationType.MOCK)
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
{
try
{
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1);
return (indicator.check(instance).getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
}).get();
assertThat(status).isEqualTo(HealthStatus.UP);
}
}
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.crm;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction;
import com.kingsrook.qqq.api.model.APIVersion;
import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput;
@@ -32,8 +33,8 @@ import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -100,29 +101,52 @@ class CrmPublicationHelpTest extends BaseTest
/*******************************************************************************
** Native registration is safe without help tables and covers all CRM metadata.
** Help covers app every table main field process and enum.
*******************************************************************************/
@Test
void helpCoversOwnedMetadataWithoutHelpTable()
void helpCoversAppEveryTableMainFieldProcessAndEnum() throws Exception
{
var entries = CrmHelpBundleStandIn.entries();
var instance = QContext.getQInstance();
assertThat(instance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
assertThat(entries).containsKey("app:crm;slot:help");
for(var table : instance.getTables().values())
{
assertThat(bundle.getId()).isEqualTo("qbit-crm");
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
assertThat(entries).containsKey("table:" + table.getName() + ";slot:help");
assertThat(table.getHelpContent().get("help")).isNotEmpty();
if(CrmAppMetaData.MAIN_TABLES.contains(table.getName()))
{
for(var field : table.getFields().values())
{
assertThat(entries).containsKey("table:" + table.getName() + ";field:" + field.getName());
assertThat(field.getHelpContents()).as(table.getName() + "." + field.getName()).isNotEmpty();
}
}
}
for(var process : instance.getProcesses().values())
{
if(process.getName().startsWith("crm_"))
{
assertThat(entries).containsKey("process:" + process.getName() + ";slot:help");
// Core creates dotted bulk-process names after QBit production; they retain core screen help.
if(!process.getName().contains("."))
{
process.getStepList().stream().filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast)
.forEach(step -> assertThat(step.getHelpContents()).isNotEmpty());
}
}
}
for(var pvs : instance.getPossibleValueSources().values())
{
if(pvs.getType() == QPossibleValueSourceType.ENUM && pvs.getName().startsWith("Crm"))
{
assertThat(entries).containsKey("possibleValueSource:" + pvs.getName() + ";slot:help");
}
}
entries.values().forEach(entry ->
{
assertThat(entry.get("summary").asText()).hasSizeLessThanOrEqualTo(120).isNotBlank();
assertThat(entry.get("content").asText()).isNotBlank();
});
HelpBundles.syncOnStartup(instance);
HelpBundleAssertions.forInstance(instance)
.bundles("help/qbit-crm.yaml")
.owning(target -> target.name().equals("crm") || target.name().startsWith("crm_") || target.name().startsWith("Crm"))
.assertComplete();
// Core's gate validates these entries but does not require widget or possible-value coverage.
var entries = HelpBundles.of(instance).resolveWinningEntries();
instance.getWidgets().keySet().stream().filter(name -> name.startsWith("crm_")).forEach(name ->
assertThat(entries).containsKey(new HelpBundles.EntryKey("widget:" + name + ";slot:label", "ALL_SCREENS")));
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("crm_") || name.startsWith("Crm")).forEach(name ->
assertThat(entries).containsKey(new HelpBundles.EntryKey("possibleValueSource:" + name + ";slot:help", "ALL_SCREENS")));
}
}
@@ -62,7 +62,7 @@ class CrmQBitProducerTest extends BaseTest
assertThat(metaData).isNotNull();
assertThat(metaData.getGroupId()).isEqualTo(CrmQBitProducer.GROUP_ID);
assertThat(metaData.getArtifactId()).isEqualTo(CrmQBitProducer.ARTIFACT_ID);
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
assertThat(metaData.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
}
@@ -114,7 +114,7 @@ class CrmQBitProducerTest extends BaseTest
{
assertThat(CrmQBitProducer.GROUP_ID).isEqualTo("com.kingsrook.qbits");
assertThat(CrmQBitProducer.ARTIFACT_ID).isEqualTo("crm");
assertThat(CrmQBitProducer.VERSION).isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(CrmQBitProducer.VERSION).isEqualTo("0.2.0-SNAPSHOT");
}
/*******************************************************************************
@@ -31,7 +31,8 @@ import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -159,17 +160,18 @@ class CrmTablesGuardrailTest extends BaseTest
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
HelpBundles.of(qInstance()).getBundles().getFirst().getEntries()
.forEach(entry -> assertThat(entry.getSummary()).as(entry.getKey()).hasSizeLessThanOrEqualTo(120));
assertThat(CrmHelpBundleStandIn.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : crmTables())
{
assertThat(tableHelp(table)).as(table + " help says how rows get created").contains("**How rows get created:**");
List<QHelpContent> help = qInstance().getTable(table).getHelpContent() == null ? null : qInstance().getTable(table).getHelpContent().get("help");
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("**How rows get created:**");
}
for(String table : NOT_WRITTEN_YET)
{
assertThat(tableHelp(table)).as(table + " says it is not used yet").contains("not used yet");
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table + " says it is not used yet").contains("not used yet");
}
assertThat(HelpBundles.of(qInstance()).resolveWinningEntries()).containsKey(new HelpBundles.EntryKey("app:crm;slot:help", "ALL_SCREENS"));
assertThat(qInstance().getApp("crm").getHelpContent()).containsKey("help");
}
@@ -182,26 +184,14 @@ class CrmTablesGuardrailTest extends BaseTest
{
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
{
assertThat(tableHelp(table)).as(table).contains("seeds");
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table).contains("seeds");
}
}
/*******************************************************************************
** Read the winning table entry from the registered native bundle.
*******************************************************************************/
private String tableHelp(String table)
{
var entry = HelpBundles.of(qInstance()).resolveWinningEntries().get(new HelpBundles.EntryKey("table:" + table + ";slot:help", "ALL_SCREENS"));
assertThat(entry).as(table + " table help").isNotNull();
return (entry.entry().getContent());
}
/*******************************************************************************
** Test instance after CRM production.
**
*******************************************************************************/
private com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance()
{
@@ -1,72 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
/*******************************************************************************
** Test authentication module that behaves like the token-based core modules
** (OAuth2, Auth0): a QSystemUserSession is always valid, a session it created
** is valid, and any other session has no token and fails validation with the
** same message the OAuth2 module produces (JWTDecodeException: The token is
** null).
*******************************************************************************/
public class TokenRequiredAuthenticationModule implements QAuthenticationModuleInterface
{
private static final Set<String> ISSUED = ConcurrentHashMap.newKeySet();
/*******************************************************************************
** Issue a session that carries a token.
*******************************************************************************/
@Override
public QSession createSession(QInstance qInstance, Map<String, String> context) throws QAuthenticationException
{
QSession session = new QSession();
ISSUED.add(session.getUuid());
return (session);
}
/*******************************************************************************
** Accept the system user and issued sessions; reject tokenless sessions.
*******************************************************************************/
@Override
public boolean isSessionValid(QInstance instance, QSession session)
{
if(session instanceof QSystemUserSession || ISSUED.contains(session.getUuid()))
{
return (true);
}
throw (new IllegalStateException("The token is null"));
}
}
@@ -23,21 +23,12 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -47,18 +38,19 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CrmSetupStatusTest extends BaseTest
{
/*******************************************************************************
** Nothing configured: the business setup the CRM needs are flagged.
** Nothing configured: the four things the CRM needs are flagged.
*******************************************************************************/
@Test
void emptyInstallNeedsSetup() throws Exception
{
Map<String, CrmSetupStatus.Item> items = items();
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency");
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency", "Email delivery");
assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails");
assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity");
assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Email delivery").state()).isNotEqualTo(CrmSetupStatus.State.TODO);
}
@@ -114,7 +106,7 @@ class CrmSetupStatusTest extends BaseTest
{
assertThat(CrmHomeWidgets.HOME).contains("crm_setup");
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows()).hasSize(4);
assertThat(data.getRows()).hasSize(5);
CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline");
assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup");
@@ -122,76 +114,6 @@ class CrmSetupStatusTest extends BaseTest
/*******************************************************************************
** The checklist is business setup only: no email-provider row.
*******************************************************************************/
@Test
void noEmailProviderRow() throws Exception
{
assertThat(items().keySet()).noneMatch(label -> label.toLowerCase().contains("email"));
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows().toString()).doesNotContain("email qbit").doesNotContain("Email delivery");
}
/*******************************************************************************
** Settings holders get the card while setup is incomplete, and not once complete.
*******************************************************************************/
@Test
void settingsHolderSeesCardOnlyWhileIncomplete() throws Exception
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isTrue();
completeSetup();
assertThat(CrmSetupStatus.isIncomplete()).isFalse();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** A CRM user without the settings permission never gets the card, incomplete or not.
*******************************************************************************/
@Test
void nonSettingsUserNeverSeesCard() throws Exception
{
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE));
assertThat(CrmSetupStatus.isIncomplete()).isTrue();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Render the setup widget the way the UI API does, with permissions enforced.
*******************************************************************************/
private void renderEnforced() throws Exception
{
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_setup"));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new RenderWidgetAction().execute(input);
}
/*******************************************************************************
** Seed everything the checklist asks for.
*******************************************************************************/
private void completeSetup() throws Exception
{
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
}
/*******************************************************************************
**
*******************************************************************************/