Compare commits

..
Author SHA1 Message Date
james.maesandCodex 98e3771c27 feat(crm): support a shared product catalog
Add optional stable slugs and catalog pricing, plus opt-in product permissions without broadening other CRM access. Include additive schema migration and upgrade tests.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:06:32 -05:00
james.maes a8e9a34b51 Merge pull request #22 from QRun-IO/feature/crm-setup-admin-only
fix(crm): Setup card only for settings holders while setup is incomplete
2026-10-16 10:19:22 -05:00
james.maesandClaude Sonnet 5.5 26b8847b46 fix(crm): show the Setup card only to CRM settings holders while setup is incomplete
The crm_setup widget was the 2nd widget on the CRM home, rendered to every
CRM user even when complete ("Nothing to do"), and carried a provider row
(Email delivery: sends through the email qbit). That is system/config content
on a business home.

- crm_setup now requires crm.manage (the permission guarding the CRM Settings
  tables) via widget rules, plus CrmSetupVisibility, a request-time custom
  permission checker (same mechanism as crm_goals) that denies once no
  business step is missing. Denied widgets are omitted (DenyBehavior.HIDDEN)
  from metadata and render.
- Removed the Email delivery row; the checklist is business setup only.
- Help text updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:10:51 -05:00
james.maes 820474805d Merge pull request #21 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:28 -05:00
james.maesandClaude Opus 5.5 5157f81208 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:11 -05:00
james.maes c6031c26ed Merge pull request #20 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 08e880af2e fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-crm.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:22:58 -05:00
james.maes e3f20d357e Merge pull request #18 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 4710ed38b3 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:05:02 -05:00
james.maes 0ebbeefa08 Merge pull request #17 from QRun-IO/feature/help-bundle
feat(help): qbit-crm help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
24 changed files with 521 additions and 26 deletions

No files matched your search

+1
View File
@@ -21,5 +21,6 @@ jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
secrets: inherit
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+5
View File
@@ -2,6 +2,11 @@
## Unreleased / 0.2.0
- Add an optional unique catalog slug and allow absent catalog prices without changing required deal-line prices. Ship the additive `crm-product-catalog.changelog.sql`; hosts must include it before adopting the metadata.
- Add opt-in `productPermissionBase` configuration for native catalog-only read/write permissions. Existing hosts retain `crm.manage` behavior; opting into `crm.product` requires the role migration documented in UPGRADE.md.
- 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.
+1
View File
@@ -56,6 +56,7 @@ qInstance.setEnforcePermissionsInCoreActions(true);
- `defaultBackendNameForTables`: required backend name.
- `inferBackendNames`: defaults to true; sets snake_case SQL column names while Java/QQQ field names remain camelCase. SQL table names equal the QQQ table names. Set false only for an explicit host column mapping.
- `withProductPermissionBase("crm.product")`: opt into catalog-only `crm.product.read/write` permissions. The default remains `crm.manage` for existing hosts. Before opting in, preserve existing catalog access by adding the corresponding product grants to roles that already hold `crm.manage.read/write`; Marketing catalog readers receive only `crm.product.read`. This does not grant CRM app/settings access or product writes to Marketing. See UPGRADE.md for the required schema and role migration.
- `tableMetaDataCustomizer`: optional host table customizer.
- `withTenantSecurityKeyType("tenant")`: enables nullable `tenantKey` fields and record locks. It defaults to null (off). `withTenantAllAccessKeyName("allTenants")` names the host's all-access key. Existing host key types are preserved.
- Tenant-enabled hosts separately include `src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql` **after** the base changelog and explicitly select the `crm-tenancy` context. Single-tenant hosts omit that file. Child records use parent join locks; polymorphic external mappings and internal job checkpoints carry direct tenant keys. Hosts must supply tenant keys on new direct-key records.
+17
View File
@@ -1,5 +1,22 @@
# Upgrading
## Shared product catalog (unreleased)
`crm_product` now supports an optional unique `slug` (64 characters) and an absent catalog `unit_price` for informational products. No price is invented. Deal-line `unit_price` remains required; existing prices and amounts are unchanged.
Before running the updated metadata against an existing schema, include `db/liquibase/qbit-crm/crm-product-catalog.changelog.sql` after the base CRM schema. This additive migration also works for hosts that copied the old CRM DDL; do not replay the base changelog against an already-created schema. Fresh installs using `crm.changelog.sql` must include the catalog changelog too. `docs/ddl/postgres.sql` describes the final schema, so hosts creating tables directly from that file must not then replay the additive migration.
The migration adds nullable `slug`, its unique constraint, and drops only the catalog price NOT NULL constraint. It preserves product IDs, timestamps, prices and tenant keys. It does not migrate Marketing data or alter any deal-line price. Roll back application code only after checking schema compatibility; do not drop populated slugs or force null prices to zero.
Catalog permissions stay on `crm.manage.read/write` unless the host opts into `withProductPermissionBase("crm.product")`. Opt-in requires an explicit role migration in the same adoption rollout:
- Existing `crm.manage.read` holders retain that grant and gain `crm.product.read`.
- Existing `crm.manage.write` holders retain that grant and gain `crm.product.write` (preserve their existing read grant separately).
- Marketing roles needing catalog selection receive `crm.product.read` only. Their marketing permissions remain unchanged.
- Do not grant `crm.manage` to Marketing users or infer permissions from navigation placement.
The native QQQ permission checks enforce both API writes and UI operation checks. Tenant locks continue to apply. Marketing integration and host data migration must be adopted separately before retiring `marketing_product`.
## 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,
+4 -2
View File
@@ -565,13 +565,15 @@ CREATE TABLE IF NOT EXISTS crm_product (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(250) NOT NULL,
sku VARCHAR(100),
slug VARCHAR(64),
description TEXT,
unit_price DECIMAL(20,6) NOT NULL,
unit_price DECIMAL(20,6),
currency_code VARCHAR(3) DEFAULT 'USD',
is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT uq_crm_product_1 UNIQUE (sku)
CONSTRAINT uq_crm_product_1 UNIQUE (sku),
CONSTRAINT uq_crm_product_slug UNIQUE (slug)
);
CREATE TABLE IF NOT EXISTS crm_sales_goal (
+28
View File
@@ -135,7 +135,35 @@
</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>
@@ -41,6 +41,7 @@ public class CrmQBitConfig implements QBitConfig
{
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
private String productPermissionBase = "crm.manage";
private String tenantSecurityKeyType;
private String tenantAllAccessKeyName;
private String apiName;
@@ -69,12 +70,45 @@ public class CrmQBitConfig implements QBitConfig
@Override
public void validate(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance, List<String> errors)
{
assertCondition(StringUtils.hasContent(productPermissionBase), "CrmQBitConfig.productPermissionBase is required", errors);
assertCondition(StringUtils.hasContent(defaultBackendNameForTables),
"CrmQBitConfig.defaultBackendNameForTables is required", errors);
}
/*******************************************************************************
** Catalog permission base. Defaults to crm.manage for existing hosts.
** Shared-catalog hosts may use crm.product after migrating role grants.
*******************************************************************************/
public String getProductPermissionBase()
{
return (productPermissionBase);
}
/*******************************************************************************
** Set the catalog's native read/write permission base.
*******************************************************************************/
public void setProductPermissionBase(String productPermissionBase)
{
this.productPermissionBase = productPermissionBase;
}
/*******************************************************************************
** Set the catalog's native read/write permission base fluently.
*******************************************************************************/
public CrmQBitConfig withProductPermissionBase(String productPermissionBase)
{
setProductPermissionBase(productPermissionBase);
return (this);
}
/*******************************************************************************
** Getter for defaultBackendNameForTables
*******************************************************************************/
@@ -26,9 +26,12 @@
package com.kingsrook.qbits.crm;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.customizers.CurrencyRateDateCustomizer;
@@ -86,12 +89,40 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "crm";
public static final String VERSION = "0.2.0-SNAPSHOT";
public static final String VERSION = loadVersion();
private CrmQBitConfig qBitConfig;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CrmQBitProducer.class.getClassLoader().getResourceAsStream("qbit-crm.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-crm.properties; build with Maven to provide the artifact version");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-crm.properties; build with Maven to provide the artifact version");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read artifact version from qbit-crm.properties", e);
}
}
/*******************************************************************************
** Produce the QBitMetaData for the CRM QBit.
*******************************************************************************/
@@ -177,6 +208,10 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CrmRelationshipValidator.class));
}
table.setPermissionRules(CrmPermissions.tableRules(tableName));
if("crm_product".equals(tableName))
{
table.getPermissionRules().setPermissionBaseName(qBitConfig.getProductPermissionBase());
}
table.setSourceQBitName(getQBitMetaData().getName());
if(table.getSections() != null)
{
@@ -87,7 +87,8 @@ public class Product extends QRecordEntity
.withRecordLabelFormat("%s")
.withRecordLabelFields("name")
.withUniqueKey(new UniqueKey("sku"))
.withSection(SectionFactory.defaultT1("id", "name", "sku", "isActive"))
.withUniqueKey(new UniqueKey("slug"))
.withSection(SectionFactory.defaultT1("id", "name", "slug", "sku", "isActive"))
.withSection(new QFieldSection("pricing", new QIcon("payments"), Tier.T2,
List.of("unitPrice", "currencyCode")))
.withSection(SectionFactory.defaultT2("description"))
@@ -145,10 +146,13 @@ public class Product extends QRecordEntity
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String sku;
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String slug;
@QField()
private String description;
@QField(isRequired = true)
@QField()
private BigDecimal unitPrice;
@QField(maxLength = 3, valueTooLongBehavior = ValueTooLongBehavior.ERROR, defaultValue = "USD")
@@ -184,6 +188,37 @@ public class Product extends QRecordEntity
/*******************************************************************************
** Optional stable catalog slug for cross-module references.
*******************************************************************************/
public String getSlug()
{
return (slug);
}
/*******************************************************************************
** Set the catalog slug.
*******************************************************************************/
public void setSlug(String slug)
{
this.slug = slug;
}
/*******************************************************************************
** Set the catalog slug fluently.
*******************************************************************************/
public Product withSlug(String slug)
{
setSlug(slug);
return (this);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
@@ -76,6 +76,11 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
{
widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class));
}
if("crm_setup".equals(name))
{
widget.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.MANAGE_BASE))
.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmSetupVisibility.class));
}
return (widget);
}
}
@@ -23,8 +23,6 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.crm.api.CrmApi;
import com.kingsrook.qbits.crm.api.CrmReadiness;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -89,6 +87,16 @@ public final class CrmSetupStatus
/*******************************************************************************
** True when any business setup step still needs attention.
*******************************************************************************/
public static boolean isIncomplete() throws QException
{
return (load().stream().anyMatch(item -> item.state() == State.TODO));
}
/*******************************************************************************
** Build the items for the current user.
*******************************************************************************/
@@ -160,14 +168,6 @@ public final class CrmSetupStatus
? new Item("Base currency", "crm_currency", State.TODO, "None: amounts have no currency prefix and conversions do nothing.", "Add your currency with Base currency ticked and rate 1.")
: new Item("Base currency", "crm_currency", State.OK, base.getFirst().getValueString("currencyCode") + " is the base currency.", null));
///////////////////////////////////////////////////////
// email: sequences and list mail need a delivery path //
///////////////////////////////////////////////////////
CrmReadiness readiness = CrmApi.readiness();
items.add(readiness == CrmReadiness.NOT_CONFIGURED
? new Item("Email delivery", "crm_email_template", State.OPTIONAL, "No email path: sequences and list email cannot send.",
"Install and configure the email qbit, or set a messaging provider on the CRM configuration.")
: new Item("Email delivery", "crm_email_template", State.OK, readiness == CrmReadiness.QBIT_EMAIL ? "Sends through the email qbit." : "Sends through core messaging.", null));
return (items);
}
@@ -0,0 +1,64 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.crm.widgets;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Show the Setup card only to CRM settings holders (crm.manage) and only while
** a business setup step is still missing. Evaluated at request time, so a
** completed setup drops the card from per-session metadata and from render.
*******************************************************************************/
public class CrmSetupVisibility implements CustomPermissionChecker
{
/*******************************************************************************
** Require crm.hasAccess and crm.manage.hasAccess, then an incomplete setup.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS) || !QContext.getQSession().hasPermission(CrmPermissions.MANAGE))
{
throw (new QPermissionDeniedException("CRM settings access is required."));
}
try
{
if(CrmSetupStatus.isIncomplete())
{
return;
}
}
catch(QException e)
{
throw (new QPermissionDeniedException("Setup status is not available.", e));
}
throw (new QPermissionDeniedException("CRM setup is complete."));
}
}
@@ -0,0 +1,7 @@
--liquibase formatted sql
-- Run after the existing CRM base schema, before deploying shared catalog metadata.
-- Existing product identities, prices, deal-line amounts and tenant keys are preserved.
--changeset qbit-crm:crm_product_shared_catalog
ALTER TABLE crm_product ADD COLUMN slug VARCHAR(64);
ALTER TABLE crm_product ADD CONSTRAINT uq_crm_product_slug UNIQUE (slug);
ALTER TABLE crm_product ALTER COLUMN unit_price DROP NOT NULL;
+6 -4
View File
@@ -1,11 +1,11 @@
bundle: qbit-crm
version: "0.2.0-SNAPSHOT"
version: ${project.version}
layer: QBIT
entries:
- key: app:crm;slot:help
summary: "Manage relationships, sales opportunities and consent."
content: |
Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. The Setup card on this page shows what still needs configuring: the default pipeline and its stages, lifecycle stages, the base currency and email delivery. Ask your administrator which reference data and base currency are configured for your installation. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport.
Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. The Setup card on this page shows what still needs configuring: the default pipeline and its stages, lifecycle stages, and the base currency. It only appears for CRM settings users, and only while a step is missing. Ask your administrator which reference data and base currency are configured for your installation. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport.
- key: table:crm_job_state;slot:help
summary: "Internal maintenance checkpoints."
content: |
@@ -521,7 +521,7 @@ entries:
content: |
Use **{{label:crm_product}}** to review products and services available as deal line items.
**How rows get created:** Add your products or services with **New**, including their prices and currencies. You can then select them on deal product lines.
**How rows get created:** Add your products or services with **New**, with a price and currency when known. Informational products may leave the catalog price blank; a deal line still requires its own agreed unit price.
- key: table:crm_product;section:identity
summary: "The identifying details for this record."
- key: table:crm_product;section:pricing
@@ -545,7 +545,9 @@ entries:
- key: table:crm_product;field:modifyDate
summary: "When this record was last changed."
- key: table:crm_product;field:unitPrice
summary: "The price for one unit of the product."
summary: "Optional catalog price per unit. Leave blank when not priced; deal lines require an agreed price."
- key: table:crm_product;field:slug
summary: "Optional unique stable identifier for this shared catalog product, up to 64 characters."
- key: table:crm_product;field:sku
summary: "The product code used to identify the item."
- key: table:crm_deal_stage_history;slot:help
+1
View File
@@ -0,0 +1 @@
version=${project.version}
@@ -37,6 +37,52 @@ import static org.assertj.core.api.Assertions.assertThat;
*******************************************************************************/
class CrmPermissionsTest extends BaseTest
{
/*******************************************************************************
** Legacy hosts retain management access; the shared catalog is opt-in.
*******************************************************************************/
@Test
void testDefaultCatalogPermissions()
{
QueryInput input = new QueryInput("crm_product");
QContext.getQSession().setPermissions(Set.of(CrmPermissions.MANAGE_READ, CrmPermissions.MANAGE_WRITE));
assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.READ)).isTrue();
assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.EDIT)).isTrue();
QContext.getQSession().setPermissions(Set.of("crm.product.read"));
assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.READ)).isFalse();
}
/*******************************************************************************
** Native catalog read grants cannot write or open unrelated CRM settings.
*******************************************************************************/
@Test
void testSharedCatalogPermissions() throws Exception
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance instance = new com.kingsrook.qqq.backend.core.model.metadata.QInstance();
instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData()
.withName("anonymous").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(QContext.getQInstance().getBackend(BACKEND_NAME));
new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME)
.withProductPermissionBase("crm.product")).produce(instance).addSelfToInstance(instance);
QContext.init(instance, QContext.getQSession());
QueryInput input = new QueryInput("crm_product");
QContext.getQSession().setPermissions(Set.of("crm.product.read"));
assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.READ)).isTrue();
for(TablePermissionSubType write : Set.of(TablePermissionSubType.INSERT, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE))
{
assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", write)).isFalse();
}
assertThat(PermissionsHelper.hasTablePermission(input, "crm_pipeline", TablePermissionSubType.READ)).isFalse();
assertThat(PermissionsHelper.hasTablePermission(input, "crm_contact", TablePermissionSubType.READ)).isFalse();
assertThat(PermissionsHelper.hasProcessPermission(input, "crm_merge_contacts")).isFalse();
QContext.getQSession().setPermissions(Set.of(CrmPermissions.MANAGE_READ, CrmPermissions.MANAGE_WRITE, "crm.product.read", "crm.product.write"));
assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.EDIT)).isTrue();
assertThat(PermissionsHelper.hasTablePermission(input, "crm_pipeline", TablePermissionSubType.EDIT)).isTrue();
}
/*******************************************************************************
** A read grant does not allow writes or settings access.
*******************************************************************************/
@@ -110,7 +110,7 @@ class CrmPublicationHelpTest extends BaseTest
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-crm");
assertThat(bundle.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
});
HelpBundles.syncOnStartup(instance);
@@ -41,6 +41,22 @@ import static org.assertj.core.api.Assertions.assertThat;
class CrmQBitConfigTest
{
/*******************************************************************************
** Catalog permission bases must be explicit; legacy behavior is the default.
*******************************************************************************/
@Test
void testCatalogPermissionBase()
{
CrmQBitConfig config = new CrmQBitConfig().withDefaultBackendNameForTables("memory");
assertThat(config.getProductPermissionBase()).isEqualTo("crm.manage");
config.setProductPermissionBase(" ");
List<String> errors = new ArrayList<>();
config.validate(new QInstance(), errors);
assertThat(errors).contains("CrmQBitConfig.productPermissionBase is required");
}
/*******************************************************************************
** Test all fluent setters and corresponding getters.
*******************************************************************************/
@@ -62,7 +62,7 @@ class CrmQBitProducerTest extends BaseTest
assertThat(metaData).isNotNull();
assertThat(metaData.getGroupId()).isEqualTo(CrmQBitProducer.GROUP_ID);
assertThat(metaData.getArtifactId()).isEqualTo(CrmQBitProducer.ARTIFACT_ID);
assertThat(metaData.getVersion()).isEqualTo(CrmQBitProducer.VERSION);
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CrmQBitProducer.VERSION);
}
@@ -114,7 +114,7 @@ class CrmQBitProducerTest extends BaseTest
{
assertThat(CrmQBitProducer.GROUP_ID).isEqualTo("com.kingsrook.qbits");
assertThat(CrmQBitProducer.ARTIFACT_ID).isEqualTo("crm");
assertThat(CrmQBitProducer.VERSION).isEqualTo("0.2.0-SNAPSHOT");
assertThat(CrmQBitProducer.VERSION).isEqualTo(System.getProperty("qbit.artifactVersion"));
}
/*******************************************************************************
@@ -281,6 +281,7 @@ class CrmRdbmsTest
var freshColumns = schemaColumns();
executeSql("DROP ALL OBJECTS;");
executeSql(changelog);
executeSql(Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm-product-catalog.changelog.sql")));
assertThat(schemaColumns()).isEqualTo(freshColumns);
insert("crm_contact", new QRecord().withValue("email", "email-only@example.test"));
}
@@ -52,6 +52,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
class DealProductTest extends BaseTest
{
/*******************************************************************************
** Optional catalog pricing does not make the agreed line price optional.
*******************************************************************************/
@Test
void testUnpricedCatalogStillRequiresLinePrice() throws QException
{
Integer productId = insertProduct("Unpriced service", "UNPRICED", null);
InsertOutput missing = new InsertAction().execute(new InsertInput(DealProduct.TABLE_NAME)
.withRecordEntity(new DealProduct().withDealId(1).withProductId(productId).withQuantity(BigDecimal.ONE)));
assertThat(missing.getRecords().get(0).getErrors()).isNotEmpty();
InsertOutput agreed = new InsertAction().execute(new InsertInput(DealProduct.TABLE_NAME)
.withRecordEntity(new DealProduct().withDealId(1).withProductId(productId).withQuantity(new BigDecimal("2"))
.withUnitPrice(new BigDecimal("12.50"))));
assertThat(agreed.getRecords().get(0).getErrors()).isEmpty();
assertThat(new QueryAction().execute(new QueryInput(DealProduct.TABLE_NAME)).getRecords().get(0).getValueBigDecimal("totalAmount")).isEqualByComparingTo("25.00");
}
/*******************************************************************************
** Verify the table is registered with the correct icon.
*******************************************************************************/
@@ -0,0 +1,76 @@
/*
* 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.deals;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.UUID;
import liquibase.Liquibase;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Upgrade an existing catalog without changing prices or product identities.
*******************************************************************************/
class ProductCatalogMigrationTest
{
/*******************************************************************************
** The real additive changeset preserves priced rows and permits unpriced
** products while enforcing non-null slug uniqueness.
*******************************************************************************/
@Test
void upgradesPopulatedCatalog() throws Exception
{
try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:catalog_" + UUID.randomUUID() + ";MODE=PostgreSQL"))
{
connection.createStatement().execute("CREATE TABLE crm_product (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(250) NOT NULL, unit_price DECIMAL(20,6) NOT NULL)");
connection.createStatement().execute("INSERT INTO crm_product(id,name,unit_price) VALUES (42,'Existing product',77.25)");
try(Liquibase liquibase = new Liquibase("db/liquibase/qbit-crm/crm-product-catalog.changelog.sql",
new ClassLoaderResourceAccessor(), new JdbcConnection(connection)))
{
liquibase.update();
liquibase.update();
try(var result = connection.createStatement().executeQuery("SELECT id,unit_price FROM crm_product"))
{
assertThat(result.next()).isTrue();
assertThat(result.getInt("id")).isEqualTo(42);
assertThat(result.getBigDecimal("unit_price")).isEqualByComparingTo("77.25");
}
connection.createStatement().execute("INSERT INTO crm_product(name,slug) VALUES ('Informational','info')");
connection.createStatement().execute("INSERT INTO crm_product(name) VALUES ('No slug one'),('No slug two')");
assertThatThrownBy(() -> connection.createStatement().execute("INSERT INTO crm_product(name,slug) VALUES ('Duplicate','info')"))
.isInstanceOf(SQLException.class);
try(var result = connection.createStatement().executeQuery("SELECT unit_price FROM crm_product WHERE slug='info'"))
{
assertThat(result.next()).isTrue();
assertThat(result.getBigDecimal(1)).isNull();
}
}
}
}
}
@@ -54,6 +54,28 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class ProductTest extends BaseTest
{
/*******************************************************************************
** An informational catalog product has no invented price; slugs are unique.
*******************************************************************************/
@Test
void testUnpricedCatalogProductAndSlug() throws QException
{
InsertOutput first = new InsertAction().execute(new InsertInput(Product.TABLE_NAME)
.withRecordEntity(new Product().withName("Documentation toolkit").withSlug("toolkit")));
assertThat(first.getRecords().get(0).getErrors()).isEmpty();
Product stored = new Product(new QueryAction().execute(new QueryInput(Product.TABLE_NAME)).getRecords().get(0));
assertThat(stored.getSlug()).isEqualTo("toolkit");
assertThat(stored.getUnitPrice()).isNull();
InsertOutput duplicate = new InsertAction().execute(new InsertInput(Product.TABLE_NAME)
.withRecordEntity(new Product().withName("Other toolkit").withSlug("toolkit")));
assertThat(duplicate.getRecords().get(0).getErrors()).isNotEmpty();
InsertOutput optional = new InsertAction().execute(new InsertInput(Product.TABLE_NAME)
.withRecords(List.of(new Product().withName("No slug one").toQRecord(), new Product().withName("No slug two").toQRecord())));
assertThat(optional.getRecords()).allSatisfy(record -> assertThat(record.getErrors()).isEmpty());
}
/*******************************************************************************
** Verify the table and PVS are registered.
*******************************************************************************/
@@ -23,12 +23,21 @@ package com.kingsrook.qbits.crm.widgets;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -38,19 +47,18 @@ import static org.assertj.core.api.Assertions.assertThat;
class CrmSetupStatusTest extends BaseTest
{
/*******************************************************************************
** Nothing configured: the four things the CRM needs are flagged.
** Nothing configured: the business setup the CRM needs are flagged.
*******************************************************************************/
@Test
void emptyInstallNeedsSetup() throws Exception
{
Map<String, CrmSetupStatus.Item> items = items();
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency", "Email delivery");
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency");
assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails");
assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity");
assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
assertThat(items.get("Email delivery").state()).isNotEqualTo(CrmSetupStatus.State.TODO);
}
@@ -106,7 +114,7 @@ class CrmSetupStatusTest extends BaseTest
{
assertThat(CrmHomeWidgets.HOME).contains("crm_setup");
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows()).hasSize(5);
assertThat(data.getRows()).hasSize(4);
CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline");
assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup");
@@ -114,6 +122,76 @@ class CrmSetupStatusTest extends BaseTest
/*******************************************************************************
** The checklist is business setup only: no email-provider row.
*******************************************************************************/
@Test
void noEmailProviderRow() throws Exception
{
assertThat(items().keySet()).noneMatch(label -> label.toLowerCase().contains("email"));
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
assertThat(data.getRows().toString()).doesNotContain("email qbit").doesNotContain("Email delivery");
}
/*******************************************************************************
** Settings holders get the card while setup is incomplete, and not once complete.
*******************************************************************************/
@Test
void settingsHolderSeesCardOnlyWhileIncomplete() throws Exception
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isTrue();
completeSetup();
assertThat(CrmSetupStatus.isIncomplete()).isFalse();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** A CRM user without the settings permission never gets the card, incomplete or not.
*******************************************************************************/
@Test
void nonSettingsUserNeverSeesCard() throws Exception
{
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE));
assertThat(CrmSetupStatus.isIncomplete()).isTrue();
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse();
assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Render the setup widget the way the UI API does, with permissions enforced.
*******************************************************************************/
private void renderEnforced() throws Exception
{
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_setup"));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new RenderWidgetAction().execute(input);
}
/*******************************************************************************
** Seed everything the checklist asks for.
*******************************************************************************/
private void completeSetup() throws Exception
{
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
}
/*******************************************************************************
**
*******************************************************************************/