Compare commits

..
1 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
25 changed files with 506 additions and 312 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.8
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.8'
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+$/]
-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
-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
+14 -62
View File
@@ -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
View File
@@ -1,16 +0,0 @@
profile: qbit
orb_version: '0.8'
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 -3
View File
@@ -2,9 +2,8 @@
## Unreleased / 0.2.0
- Register CRM health checks (`crm.email`, `crm.sequence_lag`, `crm.lead_score`) with the QQQ 4.1 core health registry (`HealthChecksMetaData`, qqq#1199), replacing the `qqq-middleware-health` indicator stand-in and its optional dependency.
- Lead scoring keeps its incremental watermark at millisecond precision, matching `modifyDate`, so a contact changed in the same millisecond a pass starts is not skipped.
- 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.
+3 -2
View File
@@ -56,15 +56,16 @@ 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.
- `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields.
- `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads.
- `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables.
- Health: the producer registers `crm.email`, `crm.sequence_lag` and `crm.lead_score` with the core health registry (`HealthChecksMetaData`). Hosts that enable core health monitoring (`HealthMetaDataProvider`) get them scheduled by the core `HealthRunner`, which runs each check as the core system user. `CrmHealthChecks.run()` evaluates them once in the caller's context. No check adds permissions or tenant keys or returns personal values.
- `CrmHealthChecks.indicators(monitoringSession)`: register the returned indicators with the host health middleware. Include optional `qqq-middleware-health`; the supplied session determines tenant scope and is restored on worker threads. `run()` uses the caller's context. No check adds permissions or tenant keys.
Help and health both use the core registries (`HelpBundles`, `HealthChecksMetaData`). See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
The shipped help bundle and health adapter are temporary core integration adapters; their removal target is RC.9. See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits.
| Scope | Permissions |
|---|---|
+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,
+11 -11
View File
@@ -89,8 +89,8 @@ These parts are real and are kept, adapted to 0.2.0:
| Imports | `importContacts` | core bulk load + saved bulk-load profiles |
| PII | none | `DataClassification.PII` on every personal field; secured-data tiers apply |
| Reporting | 14 bespoke widgets | home widgets for the daily view; trend reports via data analysis (`crm_*.analyze`) |
| Health | none | `crm.*` checks in the core health registry (`HealthChecksMetaData`) |
| Help | none | `help/qbit-crm.yaml` bundle registered with core `HelpBundles` |
| Health | none | `crm` health checks (stand-in until core #1199) |
| Help | none | `help/qbit-crm.yaml` bundle (stand-in loader until core #1198) |
| Tenancy | clientId lock on 3 tables | configurable tenant key type on every table; the host maps Authentik claims to keys |
## Data model (33 tables: 6 main, 15 settings, 12 sub-tables)
@@ -268,14 +268,13 @@ signals are logged.
### Health
`CrmHealthChecks` registers three checks with the core health registry
(`HealthChecksMetaData`, component `crm`):
- `crm.email`: email transport readiness
- `crm.sequence_lag`: the oldest due enrollment more than 30 min late
- `crm.lead_score`: the scoring job's last success time
`CrmHealthChecks` reports three checks:
- email transport readiness
- sequence lag (the oldest due enrollment more than 30 min late)
- the scoring job's last success time
The core `HealthRunner` schedules them on hosts that enable health monitoring and runs each one
as the core system user. A failing check reports UNKNOWN with a fixed message.
It is a `STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)` adapter onto the
`qqq-middleware-health` `HealthIndicator` until the central `HealthCheck` SPI ships.
### Help
@@ -283,8 +282,9 @@ as the core system user. A failing check reports UNKNOWN with a fixed message.
- the app
- every table, field, action and possible-value source
The producer registers it with core `HelpBundles.register`; `HelpBundleAssertions` checks that it
is complete.
`HelpBundleAssertions` checks that it is complete. Until `HelpBundles.register` is in a core RC,
`CrmHelpBundleStandIn` (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)`) loads the same
file into `QHelpContent`.
### Contingent dropdowns
+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 (
+8 -4
View File
@@ -22,7 +22,6 @@
<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>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -34,7 +33,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
<version>2.1.2</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -79,11 +78,16 @@
<artifactId>qqq-middleware-api</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-health</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>0.1.0-RC.11</version>
<version>0.1.0-RC.3</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -235,7 +239,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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
*******************************************************************************/
@@ -46,7 +46,6 @@ 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.health.CrmHealthChecks;
import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer;
import com.kingsrook.qbits.crm.security.CrmDataPolicy;
import com.kingsrook.qbits.crm.security.CrmPermissions;
@@ -152,7 +151,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
}
CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig);
CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig);
CrmHealthChecks.register(qInstance, qBitConfig);
for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
{
widget.setName(snakeCase(widget.getName()));
@@ -210,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
*******************************************************************************/
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.crm.health;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.CrmQBitConfig;
@@ -33,15 +32,9 @@ import com.kingsrook.qbits.crm.email.CrmEmailTransport;
import com.kingsrook.qbits.crm.scoring.JobState;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
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.HealthCheck;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
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;
@@ -49,25 +42,24 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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;
/*******************************************************************************
** CRM checks registered with the core health registry (HealthChecksMetaData).
** The core HealthRunner schedules them and runs each one in its own automated
** system-user context; no check returns personal values.
** 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.
*******************************************************************************/
public class CrmHealthChecks
{
public static final String EMAIL = "crm.email";
public static final String SEQUENCE_LAG = "crm.sequence_lag";
public static final String LEAD_SCORE = "crm.lead_score";
private static final String COMPONENT = "crm";
private final CrmEmailTransport transport;
/*******************************************************************************
** Use the same readiness decision as actual email delivery.
*******************************************************************************/
@@ -77,7 +69,6 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Allow a host transport adapter.
*******************************************************************************/
@@ -87,141 +78,121 @@ public class CrmHealthChecks
}
/*******************************************************************************
** Register the CRM checks with the instance's core health registry.
** Production may run repeatedly; an already registered name is kept.
** Run all checks without registering tables or starting background workers.
*******************************************************************************/
public static void register(QInstance instance, CrmQBitConfig config)
public Map<String, HealthCheckResult> run() throws QException
{
new CrmHealthChecks(config).registerWith(instance);
return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore()));
}
/*******************************************************************************
** Register these checks (with this transport) with the core health registry.
** 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.
*******************************************************************************/
public void registerWith(QInstance instance)
public List<HealthIndicator> indicators()
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
add(registry, EMAIL, "CRM email transport", HealthCategory.EMAIL, 300);
add(registry, SEQUENCE_LAG, "CRM sequence lag", HealthCategory.JOBS, 300);
add(registry, LEAD_SCORE, "CRM lead scoring", HealthCategory.JOBS, 900);
return (indicators(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)
{
return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session)));
}
/*******************************************************************************
** Run all checks once in the caller's context, without registering anything.
** One independently evaluated middleware indicator.
*******************************************************************************/
public Map<String, HealthCheckOutcome> run() throws QException
private class Indicator implements HealthIndicator
{
Map<String, HealthCheckOutcome> outcomes = new LinkedHashMap<>();
for(String name : List.of(EMAIL, SEQUENCE_LAG, LEAD_SCORE))
private final String name;
private final QSession session;
/*******************************************************************************
** Select a check.
*******************************************************************************/
private Indicator(String name, QSession session)
{
outcomes.put(name, check(name).run(new HealthCheckInput()));
this.name = name;
this.session = session;
}
return (outcomes);
}
/*******************************************************************************
** The core SPI body for one named check. Failures are reported as UNKNOWN
** with a fixed message, so no exception text (or value) leaves the check.
*******************************************************************************/
public HealthCheck check(String name)
{
return (new HealthCheck()
@Override
public String getName()
{
/*******************************************************************
** Evaluate the named check in the current QContext.
*******************************************************************/
@Override
public HealthCheckOutcome run(HealthCheckInput input)
return (name);
}
@Override
public HealthCheckResult check(QInstance instance)
{
CapturedContext original = QContext.capture();
try
{
try
QContext.init(instance, session != null ? session : new QSystemUserSession(instance));
return (switch(name)
{
return (switch(name)
{
case EMAIL -> email();
case SEQUENCE_LAG -> sequenceLag();
case LEAD_SCORE -> leadScore();
default -> throw (new IllegalArgumentException("Unknown CRM health check"));
});
}
catch(QException | RuntimeException e)
{
return (HealthCheckOutcome.unknown("CRM check could not be completed"));
}
case "crm.email" -> email();
case "crm.sequenceLag" -> sequenceLag();
default -> leadScore();
});
}
catch(QException | RuntimeException e)
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed"));
}
finally
{
QContext.init(original);
}
});
}
/*******************************************************************************
** Add one check definition unless the name is already registered.
*******************************************************************************/
private void add(HealthChecksMetaData registry, String name, String label, HealthCategory category, int intervalSeconds)
{
if(registry.getChecks().stream().anyMatch(existing -> name.equals(existing.getName())))
{
return;
}
registry.withCheck(new HealthCheckMetaData()
.withName(name)
.withLabel(label)
.withComponent(COMPONENT)
.withCategory(category)
.withKind(HealthCheckKind.DEPENDENCY)
.withSeverity(AlertSeverity.WARNING)
.withIntervalSeconds(intervalSeconds)
.withTimeoutMs(5000)
.withCheckInstance(check(name)));
}
/*******************************************************************************
** An unconfigured transport is degraded, not a claim that email can be sent.
*******************************************************************************/
private HealthCheckOutcome email() throws QException
private HealthCheckResult email() throws QException
{
return (transport.readiness() == CrmReadiness.NOT_CONFIGURED
? HealthCheckOutcome.degraded("Email transport not configured")
: HealthCheckOutcome.up("Email transport ready"));
return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP));
}
/*******************************************************************************
** Query just the oldest active due enrollment.
*******************************************************************************/
private HealthCheckOutcome sequenceLag() throws QException
private HealthCheckResult sequenceLag() throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId()))
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN, Instant.now().minus(30, ChronoUnit.MINUTES)))
.withOrderBy(new QFilterOrderBy("nextStepDate")).withLimit(1))).getRecords();
return (rows.isEmpty()
? HealthCheckOutcome.up("No sequence step is more than 30 minutes late")
: HealthCheckOutcome.degraded("A sequence step is more than 30 minutes late"));
return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED));
}
/*******************************************************************************
** No successful score pass, or one more than three hours old, needs attention.
*******************************************************************************/
private HealthCheckOutcome leadScore() throws QException
private HealthCheckResult leadScore() throws QException
{
List<QRecord> rows = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME).withFilter(new QQueryFilter(
new QFilterCriteria("name", QCriteriaOperator.EQUALS, CalculateLeadScoresProcess.NAME)).withLimit(1))).getRecords();
Instant last = rows.isEmpty() ? null : rows.getFirst().getValueInstant("lastSuccess");
return (last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
? HealthCheckOutcome.degraded("No lead score pass succeeded in the last 3 hours")
: HealthCheckOutcome.up("Lead scores are current"));
return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS))
? HealthStatus.DEGRADED : HealthStatus.UP));
}
}
@@ -105,11 +105,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
////////////////////////////////////////////////////////////////////////
// modifyDate is kept at millisecond precision; a microsecond watermark //
// would skip a change made in the same millisecond the pass started. //
////////////////////////////////////////////////////////////////////////
Instant startedAt = Instant.now().truncatedTo(ChronoUnit.MILLIS);
Instant startedAt = Instant.now();
List<QRecord> states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME)
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords();
QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst();
@@ -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;
+4 -2
View File
@@ -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
@@ -33,19 +33,14 @@ import com.kingsrook.qbits.crm.health.CrmHealthChecks;
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
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.QInstance;
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.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,41 +51,27 @@ import static org.assertj.core.api.Assertions.assertThat;
class CrmHealthBulkLoadTest extends BaseTest
{
/*******************************************************************************
** No transport or score run needs attention; the producer registered the
** same checks with the core health registry.
** No transport or score run needs attention.
*******************************************************************************/
@Test
void noTransportOrScoreRunNeedsAttention() throws Exception
{
var checks = new CrmHealthChecks(new CrmQBitConfig());
var results = checks.run();
assertThat(results.get(CrmHealthChecks.EMAIL).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.UP);
for(HealthCheckMetaData registered : crmChecks(QContext.getQInstance()))
assertThat(results.get("crm.email").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(results.get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.UP);
checks.indicators().forEach(indicator ->
{
assertThat(registered.getCheckInstance().run(new HealthCheckInput()).getStatus()).isEqualTo(results.get(registered.getName()).getStatus());
}
}
/*******************************************************************************
** The producer registers exactly the three CRM checks as valid core
** definitions, and re-registration does not duplicate them.
*******************************************************************************/
@Test
void producerRegistersCoreHealthChecks()
{
QInstance instance = QContext.getQInstance();
assertThat(crmChecks(instance)).extracting(HealthCheckMetaData::getName)
.containsExactlyInAnyOrder(CrmHealthChecks.EMAIL, CrmHealthChecks.SEQUENCE_LAG, CrmHealthChecks.LEAD_SCORE);
assertThat(crmChecks(instance)).allSatisfy(check ->
{
assertThat(check.getCheckInstance()).isNotNull();
assertThat(check.getTimeoutMs()).isLessThan(check.getIntervalSeconds() * 1000);
try
{
assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
});
CrmHealthChecks.register(instance, new CrmQBitConfig());
assertThat(crmChecks(instance)).hasSize(3);
}
@@ -120,8 +101,8 @@ class CrmHealthBulkLoadTest extends BaseTest
.withValue("lastSuccess", Instant.now().minus(181, ChronoUnit.MINUTES)));
com.kingsrook.qbits.crm.core.CrmRecords.update("crm_sequence_enrollment", new QRecord().withValue("id", 1)
.withValue("nextStepDate", Instant.now().minus(31, ChronoUnit.MINUTES)));
assertThat(checks.run().get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(checks.run().get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(checks.run().get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(checks.run().get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED);
}
@@ -139,7 +120,7 @@ class CrmHealthBulkLoadTest extends BaseTest
throw (new IllegalStateException("unavailable"));
}
});
assertThat(checks.check(CrmHealthChecks.EMAIL).run(new HealthCheckInput()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
}
@@ -182,36 +163,27 @@ class CrmHealthBulkLoadTest extends BaseTest
}
/*******************************************************************************
** The core HealthRunner runs each check on a worker thread with no caller
** QContext, as a QSystemUserSession. A registered CRM check must succeed that
** way on a host whose authentication rejects tokenless sessions (OAuth2, Auth0).
** Health middleware invokes checks in worker threads without a caller QContext.
*******************************************************************************/
@Test
void registeredChecksRunAsCoreSystemUserOnTokenRequiredHost() throws Exception
void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception
{
var instance = QContext.getQInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("token-required")
.withType(QAuthenticationType.MOCK)
.withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class)));
HealthCheckMetaData sequenceLag = crmChecks(instance).stream()
.filter(check -> CrmHealthChecks.SEQUENCE_LAG.equals(check.getName())).findFirst().orElseThrow();
var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1);
var status = java.util.concurrent.CompletableFuture.supplyAsync(() ->
{
assertThat(QContext.getQInstance()).isNull();
QContext.init(instance, new QSystemUserSession(instance));
try
{
return (sequenceLag.getCheckInstance().run(new HealthCheckInput().withQInstance(instance)).getStatus());
assertThat(QContext.getQInstance()).isNull();
var result = indicator.check(instance);
assertThat(QContext.getQInstance()).isNull();
assertThat(QContext.getQSession()).isNull();
return (result.getStatus());
}
catch(Exception e)
{
throw (new AssertionError(e));
}
finally
{
QContext.clear();
}
}).get();
assertThat(status).isEqualTo(HealthStatus.UP);
assertThat(QContext.getQInstance()).isSameAs(instance);
@@ -220,12 +192,32 @@ class CrmHealthBulkLoadTest extends BaseTest
/*******************************************************************************
** The CRM definitions in the core health registry.
** 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.
*******************************************************************************/
private static List<HealthCheckMetaData> crmChecks(QInstance instance)
@Test
void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception
{
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
.filter(check -> "crm".equals(check.getComponent())).toList());
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);
}
}
@@ -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.
*******************************************************************************/
@@ -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.
*******************************************************************************/
@@ -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.
*******************************************************************************/