Populate and explain every CRM table #15
No files matched your search
@@ -10,3 +10,8 @@
|
||||
- Apply CRM, management and admin permission rules and source-QBit metadata, including an RC.6 widget provenance adapter.
|
||||
- Ship PostgreSQL DDL, 33 Liquibase changesets, and H2 persistence and schema tests. 0.2.0 is a fresh install; there is no upgrade path from 0.1.0.
|
||||
- Correct installation instructions and document the existing P1 behavior and remaining limitations.
|
||||
- Add `crm-defaults.changelog.sql`: insert-only starter data (Sales Pipeline and stages, lifecycle stages, lead sources, industries, win/loss reasons, contact roles, activity types and outcomes, USD base currency). See UPGRADE.md.
|
||||
- Add a Setup card to the CRM home (default pipeline, stages, lifecycle stages, base currency, email delivery).
|
||||
- Every table help now says how its rows get created; `crm_external_mapping` is documented as not written by the CRM yet.
|
||||
- Stamp `crm_currency.rateLastUpdatedDate` when a rate is set or changed; the field is now read-only.
|
||||
- Add `CrmTablesGuardrailTest` and a Liquibase-driven defaults test (liquibase-core as a test dependency).
|
||||
@@ -77,6 +77,16 @@ The shipped help bundle and health adapter are temporary core integration adapte
|
||||
|
||||
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The app and widget provenance adapters supply core's `SourceQBitAware` interface because RC.6's app and widget classes lack it.
|
||||
|
||||
## Starter defaults
|
||||
|
||||
Include `db/liquibase/qbit-crm/crm-defaults.changelog.sql` in the host changelog after `crm.changelog.sql` and, on a tenant-enabled install, after `crm-tenancy.changelog.sql` as well:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
It seeds, insert-only: the activity types and call/meeting outcomes; the default `Sales Pipeline` (Qualification 10%, Discovery 25%, Proposal 50%, Negotiation 75%, Closed Won 100%, Closed Lost 0%); lifecycle stages (the code looks up `Lead` and `Opportunity` by name); lead sources (`Website` is the web-form default); industries; win and loss reasons (prefixed `Won:` / `Lost:` because names are unique); contact roles; and a USD base currency. Lists are seeded only while empty, so a configured host is unchanged. The lifecycle stages (they change what intake and Convert lead assign) and the base currency are seeded only on a fresh install (no contact, respectively no deal, yet), and the stages only when `Sales Pipeline` is the only pipeline. Rollbacks are empty, so they never delete host rows. Tenant-enabled hosts seed per tenant (the changelog skips tenant-keyed tables). The Setup card on the CRM home shows what is still missing.
|
||||
|
||||
## Tables
|
||||
|
||||
All 34 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata.
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Upgrading
|
||||
|
||||
## 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,
|
||||
converting a lead fails with "no default pipeline found", new web leads and converted contacts silently get no
|
||||
lifecycle stage, and amounts have no currency prefix.
|
||||
|
||||
What a host must do:
|
||||
|
||||
1. Add one line to its master changelog, after `crm.changelog.sql` and, on tenant-enabled installs, after `crm-tenancy.changelog.sql` (the file checks for `tenant_key` columns, so the order matters):
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
2. Review what it seeds (see README, "Starter defaults"). Existing configuration is never touched: lists are seeded
|
||||
only while their table is empty, the pipeline only when no pipeline exists, its stages only when it is the only pipeline and no stage or deal exists. Lifecycle stages and the base currency are seeded only on a fresh install (no contact, respectively no deal, yet), because they change what intake and Convert lead assign. Rollbacks are empty.
|
||||
3. Tenant-enabled hosts (the `crm-tenancy` changelog added `tenant_key` columns): the changelog skips those tables
|
||||
(marks them ran), because rows without a tenant key are invisible to tenants. Insert the same rows once per tenant
|
||||
with `tenant_key` set. The Setup card on the CRM home shows what is still missing.
|
||||
4. `crm_currency.rate_last_updated_date` is now written by the CRM when a rate is set or changed and is read-only in
|
||||
the UI. Existing values are kept.
|
||||
5. No schema change. `crm_external_mapping` is documented as not written by the CRM yet.
|
||||
@@ -113,6 +113,13 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
<version>4.33.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -31,9 +31,11 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.customizers.CurrencyRateDateCustomizer;
|
||||
import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.Currency;
|
||||
import com.kingsrook.qbits.crm.core.model.FormSubmission;
|
||||
import com.kingsrook.qbits.crm.deals.customizers.DealInitializationCustomizer;
|
||||
import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomizer;
|
||||
@@ -232,6 +234,15 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(DealInitializationCustomizer.class));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// stamp the exchange-rate date when a rate is set or changed //
|
||||
///////////////////////////////////////////////////////////////
|
||||
if(tableName.equals(Currency.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CurrencyRateDateCustomizer.OnInsert.class));
|
||||
addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CurrencyRateDateCustomizer.OnUpdate.class));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// wire deal product recalculation //
|
||||
///////////////////////////////////////////////////////////////
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.core.customizers;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stamps crm_currency.rateLastUpdatedDate whenever a rate is set or changed, so
|
||||
** the date says when the exchange rate was last touched. Editing only the name,
|
||||
** symbol or active flag leaves it alone.
|
||||
*******************************************************************************/
|
||||
public final class CurrencyRateDateCustomizer
|
||||
{
|
||||
public static final String RATE_FIELD = "exchangeRateToBase";
|
||||
public static final String DATE_FIELD = "rateLastUpdatedDate";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CurrencyRateDateCustomizer()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new currency has a rate from the moment it exists.
|
||||
*******************************************************************************/
|
||||
public static class OnInsert extends AbstractPreInsertCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records) throws QException
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.setValue(DATE_FIELD, now);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An update that carries a different rate than the stored one moves the date.
|
||||
*******************************************************************************/
|
||||
public static class OnUpdate extends AbstractPreUpdateCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records) throws QException
|
||||
{
|
||||
if(isPreview || oldRecordList == null)
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord oldRecord = getOldRecordMap().get(record.getValue("id"));
|
||||
if(oldRecord != null && record.getValues().containsKey(RATE_FIELD)
|
||||
&& !sameRate(ValueUtils.getValueAsBigDecimal(record.getValue(RATE_FIELD)), ValueUtils.getValueAsBigDecimal(oldRecord.getValue(RATE_FIELD))))
|
||||
{
|
||||
record.setValue(DATE_FIELD, now);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Numeric equality, so 1 and 1.000000 are the same rate.
|
||||
*******************************************************************************/
|
||||
static boolean sameRate(BigDecimal a, BigDecimal b)
|
||||
{
|
||||
return (a == null ? b == null : b != null && a.compareTo(b) == 0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -106,7 +106,7 @@ public class Currency extends QRecordEntity
|
||||
@QField(isRequired = true, defaultValue = "true")
|
||||
private Boolean isActive;
|
||||
|
||||
@QField()
|
||||
@QField(isEditable = false)
|
||||
private Instant rateLastUpdatedDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
@@ -92,6 +92,8 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
case "crm_today":
|
||||
return (new RenderWidgetOutput(new ParentWidgetData().withChildWidgetNameList(CrmHomeWidgets.TODAY).withLabel("Today")));
|
||||
case "crm_setup":
|
||||
return (new RenderWidgetOutput(setup()));
|
||||
case "crm_open_pipeline":
|
||||
return (new RenderWidgetOutput(statistic(sum(DEAL, dealStatus(false), AMOUNT), true)));
|
||||
case "crm_won_month":
|
||||
@@ -247,6 +249,22 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each piece of configuration the CRM needs, its state and the
|
||||
** next step, every cell linking to the table that fixes it.
|
||||
*******************************************************************************/
|
||||
private TableData setup() throws QException
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(CrmSetupStatus.Item item : CrmSetupStatus.load())
|
||||
{
|
||||
rows.add(row(AiLinkBuilder.filteredTableHref(item.tableName(), new QQueryFilter()), item.label(),
|
||||
item.state().getLabel() + ": " + item.detail(), item.nextStep() == null ? "Nothing to do" : item.nextStep()));
|
||||
}
|
||||
return (table(rows, "Setup", "Status", "Next step"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Six calendar buckets, including the current month, with empty months kept.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -37,7 +37,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
*******************************************************************************/
|
||||
public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput>
|
||||
{
|
||||
public static final List<String> HOME = List.of("crm_today", "crm_pipeline_summary", "crm_deal_forecast", "crm_rotting_deals",
|
||||
public static final List<String> HOME = List.of("crm_today", "crm_setup", "crm_pipeline_summary", "crm_deal_forecast", "crm_rotting_deals",
|
||||
"crm_recent_web_leads", "crm_goals", "crm_sales_leaderboard");
|
||||
public static final List<String> TODAY = List.of("crm_open_pipeline", "crm_won_month", "crm_new_contacts", "crm_new_web_leads", "crm_overdue_activities");
|
||||
|
||||
@@ -49,7 +49,7 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance)
|
||||
{
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
List<String> labels = List.of("Today", "Pipeline by stage", "Forecast", "Needs attention", "Recent web leads", "Goals", "Leaderboard");
|
||||
List<String> labels = List.of("Today", "Setup", "Pipeline by stage", "Forecast", "Needs attention", "Recent web leads", "Goals", "Leaderboard");
|
||||
for(int i = 0; i < HOME.size(); i++)
|
||||
{
|
||||
output.add(widget(HOME.get(i), labels.get(i), i == 0 ? WidgetType.PARENT_WIDGET : WidgetType.TABLE));
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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 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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the CRM home: the configuration the CRM's processes depend
|
||||
** on, whether it is in place, and the next step. Reads through core actions, so
|
||||
** the signed-in user's permissions and tenant locks apply.
|
||||
*******************************************************************************/
|
||||
public final class CrmSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: in place. TODO: something the CRM needs is missing. OPTIONAL: a feature
|
||||
** that stays off until it is set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK("Set up"),
|
||||
TODO("Needs setup"),
|
||||
OPTIONAL("Optional");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
State(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items for the current user.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load() throws QException
|
||||
{
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// pipeline and stages: Convert lead needs a default pipeline //
|
||||
// with at least one stage //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
List<QRecord> defaults = CrmHomeRenderer.query("crm_pipeline", CrmHomeRenderer.equalsFilter("isDefault", true), "id", true, 1);
|
||||
if(defaults.isEmpty())
|
||||
{
|
||||
items.add(new Item("Default pipeline", "crm_pipeline", State.TODO, "None: Convert lead fails without one.",
|
||||
"Add a pipeline and tick Default (or include the crm-defaults changelog)."));
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.TODO, "A deal needs a pipeline with stages.", "Add its stages once the pipeline exists."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Default pipeline", "crm_pipeline", State.OK, defaults.getFirst().getValueString("name") + " is the default.", null));
|
||||
List<QRecord> stages = CrmHomeRenderer.query("crm_pipeline_stage", CrmHomeRenderer.equalsFilter("pipelineId", defaults.getFirst().getValueInteger("id")), "sortOrder", true, 200);
|
||||
boolean won = stages.stream().anyMatch(stage -> Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")));
|
||||
boolean lost = stages.stream().anyMatch(stage -> Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")));
|
||||
if(stages.isEmpty())
|
||||
{
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.TODO, "The default pipeline has no stages: Convert lead fails.",
|
||||
"Add its stages, in order, with a win probability each."));
|
||||
}
|
||||
else if(!won || !lost)
|
||||
{
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.TODO, stages.size() + " stage" + (stages.size() == 1 ? "" : "s") + ", but no " + (won ? "Closed Lost" : "Closed Won") + " stage.",
|
||||
"Tick Closed Won and Closed Lost on the stages that end a deal: the home, goals and reports count deals from them."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Pipeline stages", "crm_pipeline_stage", State.OK, stages.size() + " stages in the default pipeline.", null));
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// lifecycle stages: the CRM looks up "Lead" and "Opportunity" by name //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(String name : List.of("Lead", "Opportunity"))
|
||||
{
|
||||
if(CrmHomeRenderer.query("crm_lifecycle_stage", CrmHomeRenderer.equalsFilter("name", name), "id", true, 1).isEmpty())
|
||||
{
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
if(missing.isEmpty())
|
||||
{
|
||||
items.add(new Item("Lifecycle stages", "crm_lifecycle_stage", State.OK, "Lead and Opportunity exist.", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
String effect = missing.size() == 2 ? "new web leads get no stage and Convert lead does not move the contact"
|
||||
: missing.contains("Lead") ? "new web leads get no stage" : "Convert lead does not move the contact";
|
||||
items.add(new Item("Lifecycle stages", "crm_lifecycle_stage", State.TODO, "Missing " + String.join(" and ", missing) + ": " + effect + ".",
|
||||
"Add " + (missing.size() == 2 ? "stages" : "a stage") + " named exactly " + String.join(" and ", missing.stream().map(name -> "\"" + name + "\"").toList()) + "."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// currency: amounts need a base currency for the prefix //
|
||||
///////////////////////////////////////////////////////////
|
||||
List<QRecord> base = CrmHomeRenderer.query("crm_currency", CrmHomeRenderer.equalsFilter("isBaseCurrency", true), "id", true, 1);
|
||||
items.add(base.isEmpty()
|
||||
? 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,204 @@
|
||||
-- liquibase formatted sql
|
||||
-- Copyright 2026 Kingsrook, LLC. Licensed under the Apache License, Version 2.0.
|
||||
-- https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
-- qbit-crm starter defaults: a CRM that works out of the box.
|
||||
-- Include it from the host's master changelog AFTER crm.changelog.sql (or
|
||||
-- docs/ddl/postgres.sql) and, on a tenant-enabled install, AFTER
|
||||
-- crm-tenancy.changelog.sql too (so the tenant_key check below sees the columns):
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-crm/crm-defaults.changelog.sql }
|
||||
--
|
||||
-- Without these rows, converting a lead fails ("no default pipeline"), deals
|
||||
-- and web leads silently lose their lifecycle stage, and amounts have no
|
||||
-- currency prefix.
|
||||
--
|
||||
-- Rules for this file:
|
||||
-- * insert-only and never disruptive: a list is seeded only while its table is
|
||||
-- empty (a host that configured its own keeps exactly that), the pipeline
|
||||
-- only when no pipeline exists, its stages only when that is the one
|
||||
-- pipeline and no stage or deal exists. The lifecycle stages (which change
|
||||
-- what intake and Convert lead assign) and the base currency are seeded
|
||||
-- only on a fresh install: no contact (respectively no deal) exists yet.
|
||||
-- Nothing is ever updated, so an administrator's edits survive;
|
||||
-- * every rollback is empty: a rollback must never delete rows the host or a
|
||||
-- tenant owns (Liquibase runs rollbacks even for MARK_RAN changesets);
|
||||
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
|
||||
-- * not for tenant-enabled installs: once the crm-tenancy changelog has added
|
||||
-- tenant_key to a table, that table's changeset is skipped (MARK_RAN).
|
||||
-- Every row of a tenant-locked table needs a tenant key and only the host
|
||||
-- knows its tenants, so such a host seeds these same rows once per tenant,
|
||||
-- with tenant_key set;
|
||||
-- * every changeset checks that its tables exist (SELECT ... WHERE 1 = 0 errors
|
||||
-- when one is missing): a host that has not created them yet simply skips it
|
||||
-- (MARK_RAN).
|
||||
-- CrmDefaultsChangelogTest runs this file twice against a fresh schema.
|
||||
|
||||
-- changeset qbit-crm:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_activity_type WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_activity_type' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: The six activity kinds the CRM logs. Same codes and names CrmStarterData creates on first use, so that stays a no-op.
|
||||
INSERT INTO crm_activity_type (code, name, is_system, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.code, v.n, TRUE, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'NOTE' AS code, 'Note' AS n, 0 AS so
|
||||
UNION ALL SELECT 'EMAIL', 'Email', 1
|
||||
UNION ALL SELECT 'CALL', 'Call', 2
|
||||
UNION ALL SELECT 'MEETING', 'Meeting', 3
|
||||
UNION ALL SELECT 'FORM_SUBMISSION', 'Form submission', 4
|
||||
UNION ALL SELECT 'SEQUENCE_STEP', 'Sequence step', 5) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_activity_type x WHERE x.code = v.code OR x.name = v.n);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-2
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_activity_outcome WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_activity_type WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_activity_outcome' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Call and meeting outcomes, offered when an activity of that type is logged. Needs the activity types above.
|
||||
INSERT INTO crm_activity_outcome (activity_type_id, name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT t.id, v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM crm_activity_type t
|
||||
JOIN (SELECT 'CALL' AS code, 'Connected' AS n, 1 AS so
|
||||
UNION ALL SELECT 'CALL', 'Left voicemail', 2
|
||||
UNION ALL SELECT 'CALL', 'No answer', 3
|
||||
UNION ALL SELECT 'MEETING', 'Meeting held', 1
|
||||
UNION ALL SELECT 'MEETING', 'Meeting no-show', 2) v ON v.code = t.code
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_activity_outcome);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-3
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_pipeline WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_pipeline' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: The default pipeline, only when the host has no pipeline yet. Convert lead refuses to run without a default one.
|
||||
INSERT INTO crm_pipeline (name, description, is_default, is_active, create_date, modify_date)
|
||||
SELECT 'Sales Pipeline', 'Default sales process for new deals and converted leads.', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_pipeline);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-4
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_pipeline WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_pipeline_stage WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_pipeline' AND LOWER(column_name) = 'tenant_key'
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_deal WHERE 1 = 0
|
||||
-- comment: Stages of Sales Pipeline with their win probability, only when it is the one pipeline, no stage and no deal exists (that is, defaults-3 created it). Closed Won and Closed Lost end a deal.
|
||||
INSERT INTO crm_pipeline_stage (pipeline_id, name, sort_order, probability_pct, is_closed_won, is_closed_lost, create_date, modify_date)
|
||||
SELECT p.id, v.n, v.so, v.pct, v.won, v.lost, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM crm_pipeline p
|
||||
JOIN (SELECT 'Qualification' AS n, 1 AS so, 10 AS pct, FALSE AS won, FALSE AS lost
|
||||
UNION ALL SELECT 'Discovery', 2, 25, FALSE, FALSE
|
||||
UNION ALL SELECT 'Proposal', 3, 50, FALSE, FALSE
|
||||
UNION ALL SELECT 'Negotiation', 4, 75, FALSE, FALSE
|
||||
UNION ALL SELECT 'Closed Won', 5, 100, TRUE, FALSE
|
||||
UNION ALL SELECT 'Closed Lost', 6, 0, FALSE, TRUE) v ON 1 = 1
|
||||
WHERE p.name = 'Sales Pipeline'
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_pipeline_stage)
|
||||
AND (SELECT COUNT(*) FROM crm_pipeline) = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_deal);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-5
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_lifecycle_stage WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_lifecycle_stage' AND LOWER(column_name) = 'tenant_key'
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_contact
|
||||
-- comment: Lifecycle stages. The CRM looks up Lead (new web leads) and Opportunity (Convert lead) by these exact names.
|
||||
INSERT INTO crm_lifecycle_stage (name, sort_order, is_active, description, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, v.d, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Subscriber' AS n, 1 AS so, 'Signed up for updates; not yet a lead.' AS d
|
||||
UNION ALL SELECT 'Lead', 2, 'Showed interest. Web leads start here.'
|
||||
UNION ALL SELECT 'Marketing Qualified Lead', 3, 'Engaged enough for marketing to hand over.'
|
||||
UNION ALL SELECT 'Sales Qualified Lead', 4, 'Accepted by sales as worth pursuing.'
|
||||
UNION ALL SELECT 'Opportunity', 5, 'Has an open deal. Convert lead moves the contact here.'
|
||||
UNION ALL SELECT 'Customer', 6, 'Has bought.'
|
||||
UNION ALL SELECT 'Evangelist', 7, 'A happy customer who refers others.'
|
||||
UNION ALL SELECT 'Other', 8, 'Does not fit the stages above.') v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_lifecycle_stage)
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_contact);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-6
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_lead_source WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_lead_source' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Where contacts and deals come from. Website is the source the CRM's web forms use.
|
||||
INSERT INTO crm_lead_source (name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Website' AS n, 1 AS so
|
||||
UNION ALL SELECT 'Referral', 2
|
||||
UNION ALL SELECT 'Event', 3
|
||||
UNION ALL SELECT 'Email Campaign', 4
|
||||
UNION ALL SELECT 'Social', 5
|
||||
UNION ALL SELECT 'Partner', 6
|
||||
UNION ALL SELECT 'Cold Outreach', 7
|
||||
UNION ALL SELECT 'Other', 8) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_lead_source);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-7
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_industry WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_industry' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: A short generic industry list for companies.
|
||||
INSERT INTO crm_industry (name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Technology' AS n, 1 AS so
|
||||
UNION ALL SELECT 'Healthcare', 2
|
||||
UNION ALL SELECT 'Financial Services', 3
|
||||
UNION ALL SELECT 'Manufacturing', 4
|
||||
UNION ALL SELECT 'Retail', 5
|
||||
UNION ALL SELECT 'Education', 6
|
||||
UNION ALL SELECT 'Real Estate', 7
|
||||
UNION ALL SELECT 'Professional Services', 8
|
||||
UNION ALL SELECT 'Nonprofit', 9
|
||||
UNION ALL SELECT 'Other', 10) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_industry);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-8
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_win_loss_reason WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_win_loss_reason' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Why deals are won (type 1) or lost (type 2). Names are unique across both types, so each carries its outcome as a prefix.
|
||||
INSERT INTO crm_win_loss_reason (name, type, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.t, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Won: Price' AS n, 1 AS t, 1 AS so
|
||||
UNION ALL SELECT 'Won: Product fit', 1, 2
|
||||
UNION ALL SELECT 'Won: Relationship', 1, 3
|
||||
UNION ALL SELECT 'Lost: Price', 2, 4
|
||||
UNION ALL SELECT 'Lost: Competitor', 2, 5
|
||||
UNION ALL SELECT 'Lost: No budget', 2, 6
|
||||
UNION ALL SELECT 'Lost: No decision', 2, 7
|
||||
UNION ALL SELECT 'Lost: Timing', 2, 8) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_win_loss_reason);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-9
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_contact_role WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_contact_role' AND LOWER(column_name) = 'tenant_key'
|
||||
-- comment: Roles a contact can play on a deal.
|
||||
INSERT INTO crm_contact_role (name, sort_order, is_active, create_date, modify_date)
|
||||
SELECT v.n, v.so, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT 'Decision Maker' AS n, 1 AS so
|
||||
UNION ALL SELECT 'Champion', 2
|
||||
UNION ALL SELECT 'Influencer', 3
|
||||
UNION ALL SELECT 'Economic Buyer', 4
|
||||
UNION ALL SELECT 'Technical Evaluator', 5
|
||||
UNION ALL SELECT 'End User', 6) v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_contact_role);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-crm:defaults-10
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_currency WHERE 1 = 0
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'crm_currency' AND LOWER(column_name) = 'tenant_key'
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM crm_deal
|
||||
-- comment: The base currency, only when none exists: amounts need it for their prefix and conversions. Change it before the first deal if the business does not report in USD.
|
||||
INSERT INTO crm_currency (currency_code, name, symbol, exchange_rate_to_base, is_base_currency, is_active, rate_last_updated_date, create_date, modify_date)
|
||||
SELECT 'USD', 'US Dollar', '$', 1, TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM crm_currency WHERE currency_code = 'USD' OR is_base_currency = TRUE)
|
||||
AND NOT EXISTS (SELECT 1 FROM crm_deal);
|
||||
-- rollback empty
|
||||
@@ -5,59 +5,85 @@ 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. 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, the base currency and email delivery. A new installation starts with a Sales Pipeline, lifecycle stages, lead sources, industries, win and loss reasons, contact roles, activity outcomes and a USD base currency. 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: |
|
||||
Internal maintenance checkpoints. Health checks use the last successful lead-score run.
|
||||
|
||||
**How rows get created:** the CRM writes it: each scheduled maintenance run (lead scoring, deal notifications) saves its checkpoint here. You never add rows; leave them alone, as the health checks read them.
|
||||
- key: table:crm_external_mapping;slot:help
|
||||
summary: "Links between CRM records and identifiers in external systems."
|
||||
content: |
|
||||
Links between CRM records and identifiers in external systems. Keep each system mapping unique.
|
||||
Links between CRM records and identifiers in external systems. Keep each record and system pair unique.
|
||||
|
||||
**How rows get created:** not used yet: nothing in the CRM writes these rows, no sync reads them, and the table is not in the navigation. It is a reserved place for a host integration to record which external id a contact, company or deal has (one row per record and system). Erasing a contact deletes its mappings.
|
||||
- key: table:crm_assignment_rule;slot:help
|
||||
summary: "Rules for selecting the owner of a new contact or deal."
|
||||
content: |
|
||||
Rules for selecting the owner of a new contact or deal.
|
||||
|
||||
**How rows get created:** an administrator adds a rule with New (what it applies to, the criteria and a strategy: Specific User, Round Robin or Least Active); the users who may receive work go under Members. Without a rule, new contacts and deals keep the owner they were created with. The qbit seeds none because the users are yours.
|
||||
- key: table:crm_assignment_rule_member;slot:help
|
||||
summary: "Users eligible for a group assignment rule."
|
||||
content: |
|
||||
Users eligible for a group assignment rule. Membership determines who can receive assigned work.
|
||||
|
||||
**How rows get created:** an administrator adds the users who can receive work under a rule, from the rule's record. A rule with no members assigns nothing.
|
||||
- key: table:crm_lead_score_rule;slot:help
|
||||
summary: "Conditions that add or subtract lead-score points."
|
||||
content: |
|
||||
Conditions that add or subtract lead-score points. Review rule order and decay behavior.
|
||||
|
||||
**How rows get created:** an administrator adds each rule with New (entity, the field to test, an operator, a value and the score adjustment, positive or negative). Calculate lead scores applies them on its schedule, and Recalculate Score on a contact applies them now. The qbit seeds none because good scoring depends on your business.
|
||||
- key: table:crm_sales_goal;slot:help
|
||||
summary: "Revenue targets for a user and period, optionally limited to a pipeline."
|
||||
content: |
|
||||
Revenue targets for a user and period, optionally limited to a pipeline.
|
||||
|
||||
**How rows get created:** an administrator adds a target for a user and period with New, optionally for one pipeline. The Goals card on the home compares each target with the deals won in that period. The qbit seeds none because targets are yours.
|
||||
- key: table:crm_email_message;slot:help
|
||||
summary: "Delivery information and content for an email associated with a contact or activity."
|
||||
content: |
|
||||
Delivery information and content for an email associated with a contact or activity.
|
||||
|
||||
**How rows get created:** the CRM writes it: every email it sends or receives for a contact (sequence steps, list mail, replies) is stored here with its delivery status, and shows on the contact's timeline. You do not add rows by hand.
|
||||
- key: table:crm_email_sequence;slot:help
|
||||
summary: "Ordered outreach steps."
|
||||
content: |
|
||||
Ordered outreach steps. Activate only after reviewing the steps, timing and email consent requirements.
|
||||
|
||||
**How rows get created:** an administrator creates a sequence with New, then adds its steps. Contacts join it with the Enroll in Sequence action. Activate it only after the steps, timing and templates are reviewed. The qbit seeds none because the copy is yours.
|
||||
- key: table:crm_email_template;slot:help
|
||||
summary: "Reusable email subject and content."
|
||||
content: |
|
||||
Reusable email subject and content. Review merged content before sending to recipients.
|
||||
|
||||
**How rows get created:** an administrator writes each template with New (name, subject and body, with merge fields). Sequence steps and sends pick templates from here. The qbit seeds none because the wording is yours.
|
||||
- key: table:crm_sequence_enrollment;slot:help
|
||||
summary: "A contact enrolled in a sequence, with progress, status and next scheduled step."
|
||||
content: |
|
||||
A contact enrolled in a sequence, with progress, status and next scheduled step.
|
||||
|
||||
**How rows get created:** the Enroll in Sequence action creates a row for each contact, and the scheduled Process sequence steps job advances it. Unenroll from Sequence and erasing the contact end it. You do not add rows by hand.
|
||||
- key: table:crm_sequence_step;slot:help
|
||||
summary: "One timed action in an outreach sequence."
|
||||
content: |
|
||||
One timed action in an outreach sequence. Email steps require a usable template and transport.
|
||||
|
||||
**How rows get created:** an administrator adds steps from the sequence record: a type (Email, Call, Task or Wait), a delay and, for email steps, a template. Steps run in order for each enrolled contact.
|
||||
- key: table:crm_contact_role;slot:help
|
||||
summary: "Roles a contact can play in a deal or relationship."
|
||||
content: |
|
||||
Roles a contact can play in a deal or relationship.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds six roles (Decision Maker, Champion, Influencer, Economic Buyer, Technical Evaluator, End User). Add or retire your own with New. They are offered when you attach a contact to a deal.
|
||||
- key: table:crm_deal;slot:help
|
||||
summary: "Sales opportunities tracked through a pipeline."
|
||||
content: |
|
||||
Sales opportunities tracked through a pipeline. Select a pipeline before choosing its stage.
|
||||
|
||||
**How rows get created:** a person adds one with New (pick the pipeline first, then its stage), or Create Deal on a contact (Convert lead) creates one in the default pipeline. Move Stage, Mark Won, Mark Lost and Reopen are actions on the deal. Amounts convert to the base currency.
|
||||
- key: table:crm_deal;field:id
|
||||
summary: "System-assigned record identifier."
|
||||
content: |
|
||||
@@ -154,34 +180,50 @@ entries:
|
||||
summary: "Contacts involved in a deal, with their relationship or role."
|
||||
content: |
|
||||
Contacts involved in a deal, with their relationship or role.
|
||||
|
||||
**How rows get created:** a person attaches a contact to a deal with the Add Contact action on the deal, choosing a role. Create Deal (Convert lead) and Clone also create rows. Merging contacts re-points them.
|
||||
- key: table:crm_deal_product;slot:help
|
||||
summary: "Product quantities and prices on a deal."
|
||||
content: |
|
||||
Product quantities and prices on a deal. Changes recalculate the deal total.
|
||||
|
||||
**How rows get created:** a person adds a product line from the deal's record. Changing a line recalculates the deal's amount. Clone copies the lines.
|
||||
- key: table:crm_deal_stage_history;slot:help
|
||||
summary: "The history of a deal moving between pipeline stages."
|
||||
content: |
|
||||
The history of a deal moving between pipeline stages.
|
||||
|
||||
**How rows get created:** the CRM writes it: a row is added when a deal is created and every time it changes stage (including won, lost and reopen), with the time spent in the previous stage. You do not add rows by hand.
|
||||
- key: table:crm_pipeline;slot:help
|
||||
summary: "Sales pipelines that group stages."
|
||||
content: |
|
||||
Sales pipelines that group stages. Give each pipeline a clear purpose and ordered stages.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds one default pipeline, Sales Pipeline, when you have none. Add more with New and tick Default on the one new deals and converted leads should use. Create Deal (Convert lead) fails without a default pipeline that has stages.
|
||||
- key: table:crm_pipeline_stage;slot:help
|
||||
summary: "Ordered stages within a pipeline, with probability, required fields and won or lost outcomes."
|
||||
content: |
|
||||
Ordered stages within a pipeline, with probability, required fields and won or lost outcomes.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds six stages for Sales Pipeline: Qualification 10%, Discovery 25%, Proposal 50%, Negotiation 75%, Closed Won 100% and Closed Lost 0%. Add the stages of other pipelines from the pipeline's record. Tick Closed Won and Closed Lost on the stages that end a deal: the home, goals and forecasts count from them.
|
||||
- key: table:crm_product;slot:help
|
||||
summary: "Products and services available as deal line items."
|
||||
content: |
|
||||
Products and services available as deal line items. Check price and currency before adding to a deal.
|
||||
|
||||
**How rows get created:** an administrator adds each product or service with New (name, price, currency). Deal product lines pick from this catalogue. The qbit seeds none because the catalogue is yours.
|
||||
- key: table:crm_win_loss_reason;slot:help
|
||||
summary: "Reasons for winning or losing a deal."
|
||||
content: |
|
||||
Reasons for winning or losing a deal. Use these to improve pipeline reporting.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds three won reasons (Price, Product fit, Relationship) and five lost ones (Price, Competitor, No budget, No decision, Timing), each prefixed Won: or Lost:. Add your own with New. A user picks one when marking a deal won or lost.
|
||||
- key: table:crm_company;slot:help
|
||||
summary: "Organizations linked to contacts and deals."
|
||||
content: |
|
||||
Organizations linked to contacts and deals. Use domain to distinguish similarly named companies.
|
||||
|
||||
**How rows get created:** a person adds one with New, an import creates them, or Merge Companies keeps one. Contacts and deals link to it by company.
|
||||
- key: table:crm_company;field:id
|
||||
summary: "System-assigned record identifier."
|
||||
content: |
|
||||
@@ -282,10 +324,14 @@ entries:
|
||||
summary: "Immutable evidence of a consent decision."
|
||||
content: |
|
||||
Immutable evidence of a consent decision. Record withdrawals as new records; erasure redacts evidence text.
|
||||
|
||||
**How rows get created:** the CRM writes it: subscribing to a list, a web form with consent, an unsubscribe or a withdrawal each add a row of evidence. Records are never edited. You do not add rows by hand.
|
||||
- key: table:crm_contact;slot:help
|
||||
summary: "People and their relationship with your organization."
|
||||
content: |
|
||||
People and their relationship with your organization. Check consent before sending marketing email.
|
||||
|
||||
**How rows get created:** a person adds one with New, an import or bulk load creates them, a web form creates one when you convert a web lead, and Merge Contacts keeps one. Set the lifecycle stage and lead source from the lists in Settings.
|
||||
- key: table:crm_contact;field:firstTouchUtmSource
|
||||
summary: "First-touch campaign attribution: first touch utm source."
|
||||
content: |
|
||||
@@ -486,10 +532,14 @@ entries:
|
||||
summary: "Currency codes and exchange rates used to report deal values in a base currency."
|
||||
content: |
|
||||
Currency codes and exchange rates used to report deal values in a base currency.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds US Dollar as the base currency (rate 1) when you have none. Add other currencies with New and a rate to the base; changing a rate stamps Rate last updated. Change the base before your first deal if you do not report in USD. Without a base currency, amounts have no prefix and conversion does nothing.
|
||||
- key: table:crm_email_list;slot:help
|
||||
summary: "Mailing lists and opt-in settings."
|
||||
content: |
|
||||
Mailing lists and opt-in settings. List membership alone does not grant permission to send marketing email.
|
||||
|
||||
**How rows get created:** an administrator creates a list with New (name, and whether joining needs confirmation). Members join through the Add to List action or a web form. Joining a list is not consent to marketing email on its own.
|
||||
- key: table:crm_email_list;field:listKey
|
||||
summary: "A stable integration key used by hosts to subscribe people to this list."
|
||||
content: |
|
||||
@@ -550,10 +600,14 @@ entries:
|
||||
summary: "A contact or email address in a list, including opt-in state and confirmation evidence."
|
||||
content: |
|
||||
A contact or email address in a list, including opt-in state and confirmation evidence.
|
||||
|
||||
**How rows get created:** the Add to List and Remove from List actions, web forms and the public subscribe call write it, one row per contact or address and list. Merging contacts and expiring consent update it. You do not add rows by hand.
|
||||
- key: table:crm_form_submission;slot:help
|
||||
summary: "Web leads captured from forms, including consent evidence and attribution."
|
||||
content: |
|
||||
Web leads captured from forms, including consent evidence and attribution. Convert a lead to link a contact.
|
||||
|
||||
**How rows get created:** the host's public intake call writes one for each web form that arrives (contact us, sign-up, demo request). Convert to Contact turns it into a contact and links it. You do not add rows by hand.
|
||||
- key: table:crm_form_submission;field:formKey
|
||||
summary: "The stable host-defined identifier for the form that captured this submission."
|
||||
content: |
|
||||
@@ -674,18 +728,26 @@ entries:
|
||||
summary: "Industry categories for companies."
|
||||
content: |
|
||||
Industry categories for companies. Choose one that describes the primary business.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds ten generic industries (Technology, Healthcare and so on). Add or retire your own with New. A company picks one.
|
||||
- key: table:crm_lead_source;slot:help
|
||||
summary: "Where a contact or deal originated."
|
||||
content: |
|
||||
Where a contact or deal originated. Use consistent categories for attribution reports.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds eight sources: Website (the one web forms use), Referral, Event, Email Campaign, Social, Partner, Cold Outreach and Other. Add your own with New. Contacts and deals pick one for attribution reports.
|
||||
- key: table:crm_lifecycle_stage;slot:help
|
||||
summary: "Lifecycle categories for contacts, such as lead and customer."
|
||||
content: |
|
||||
Lifecycle categories for contacts, such as lead and customer.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds eight stages: Subscriber, Lead, Marketing Qualified Lead, Sales Qualified Lead, Opportunity, Customer, Evangelist and Other. Keep the names Lead and Opportunity exactly: new web leads get Lead, and Create Deal (Convert lead) moves the contact to Opportunity. A missing one is skipped silently.
|
||||
- key: table:crm_activity;slot:help
|
||||
summary: "Past interactions with contacts, companies and deals."
|
||||
content: |
|
||||
Past interactions with contacts, companies and deals. Use collaboration tasks for future work.
|
||||
|
||||
**How rows get created:** a person logs a call, meeting, email or note with the Log Activity action on a contact, company or deal, or New. Web forms and sequence steps also write them. Logging one updates the last-activity dates of what it links to.
|
||||
- key: table:crm_activity;field:id
|
||||
summary: "System-assigned record identifier."
|
||||
content: |
|
||||
@@ -794,14 +856,20 @@ entries:
|
||||
summary: "The result of an activity."
|
||||
content: |
|
||||
The result of an activity. Only outcomes belonging to the selected activity type are offered.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds Connected, Left voicemail and No answer for calls and Meeting held and Meeting no-show for meetings. Add your own with New, choosing the activity type it belongs to.
|
||||
- key: table:crm_activity_participant;slot:help
|
||||
summary: "People participating in a recorded activity and their roles."
|
||||
content: |
|
||||
People participating in a recorded activity and their roles.
|
||||
|
||||
**How rows get created:** a person adds the people on an activity from the activity's record. The CRM does not add them for you, so it stays empty until someone does.
|
||||
- key: table:crm_activity_type;slot:help
|
||||
summary: "Types of interaction such as calls, meetings and notes."
|
||||
content: |
|
||||
Types of interaction such as calls, meetings and notes. Outcomes are scoped to a type.
|
||||
|
||||
**How rows get created:** when the host includes the crm-defaults changelog, the qbit seeds Note, Email, Call, Meeting, Form submission and Sequence step, and the CRM adds any that are missing the first time it needs them. Add your own with New.
|
||||
- key: possibleValueSource:CrmActivityPriority;slot:help
|
||||
summary: "Available activity priority values."
|
||||
content: |
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped starter defaults (db/liquibase/qbit-crm/crm-defaults.changelog.sql):
|
||||
** they apply to a fresh schema built from the qbit's own changelog, re-running
|
||||
** inserts nothing and keeps an administrator's edits, an existing setup is
|
||||
** respected, and a tenant-enabled schema is left alone.
|
||||
*******************************************************************************/
|
||||
public class CrmDefaultsChangelogTest
|
||||
{
|
||||
static final String SCHEMA = "/db/liquibase/qbit-crm/crm.changelog.sql";
|
||||
static final String TENANCY = "/db/liquibase/qbit-crm/crm-tenancy.changelog.sql";
|
||||
static final String CHANGELOG = "/db/liquibase/qbit-crm/crm-defaults.changelog.sql";
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables the changelog seeds.
|
||||
*******************************************************************************/
|
||||
public static final List<String> SEEDED_TABLES = List.of("crm_activity_type", "crm_activity_outcome", "crm_pipeline", "crm_pipeline_stage",
|
||||
"crm_lifecycle_stage", "crm_lead_source", "crm_industry", "crm_win_loss_reason", "crm_contact_role", "crm_currency");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh schema, seed twice (ignoring preconditions, so the SQL guards
|
||||
** themselves are tested): same rows; an edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAFreshSchemaInsertOnly() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection, true);
|
||||
Map<String, Integer> first = counts(connection);
|
||||
assertThat(first).containsEntry("crm_activity_type", 6).containsEntry("crm_activity_outcome", 5).containsEntry("crm_pipeline", 1)
|
||||
.containsEntry("crm_pipeline_stage", 6).containsEntry("crm_lifecycle_stage", 8).containsEntry("crm_lead_source", 8)
|
||||
.containsEntry("crm_win_loss_reason", 8).containsEntry("crm_contact_role", 6).containsEntry("crm_currency", 1);
|
||||
assertThat(first.get("crm_industry")).isEqualTo(10);
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE crm_pipeline_stage SET probability_pct = 15 WHERE name = 'Qualification'");
|
||||
statement.execute("UPDATE crm_currency SET name = 'Dollar' WHERE currency_code = 'USD'");
|
||||
}
|
||||
|
||||
runChangelog(connection, true);
|
||||
assertThat(counts(connection)).isEqualTo(first);
|
||||
assertThat(scalar(connection, "SELECT probability_pct FROM crm_pipeline_stage WHERE name = 'Qualification'")).isEqualTo("15");
|
||||
assertThat(scalar(connection, "SELECT name FROM crm_currency WHERE currency_code = 'USD'")).isEqualTo("Dollar");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seeded rows are what the CRM's code looks up and relies on.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsMatchWhatTheCodeNeeds() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE is_default = TRUE")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage s JOIN crm_pipeline p ON p.id = s.pipeline_id WHERE p.is_default = TRUE")).isEqualTo("6");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage WHERE is_closed_won = TRUE AND probability_pct = 100 AND name = 'Closed Won'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage WHERE is_closed_lost = TRUE AND probability_pct = 0 AND name = 'Closed Lost'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage WHERE name IN ('Lead', 'Opportunity')")).isEqualTo("2");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source WHERE name = 'Website'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT exchange_rate_to_base FROM crm_currency WHERE is_base_currency = TRUE AND currency_code = 'USD'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_win_loss_reason WHERE type = 1")).isEqualTo("3");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_win_loss_reason WHERE type = 2")).isEqualTo("5");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_activity_outcome o JOIN crm_activity_type t ON t.id = o.activity_type_id WHERE t.code = 'CALL'")).isEqualTo("3");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_activity_outcome o JOIN crm_activity_type t ON t.id = o.activity_type_id WHERE t.code = 'MEETING'")).isEqualTo("2");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that already has its own pipeline or base currency keeps it: the
|
||||
** default pipeline is not added, and a second base currency never appears.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void keepsAnExistingSetup() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO crm_pipeline (name, is_default, is_active) VALUES ('Mine', TRUE, TRUE)");
|
||||
statement.execute("INSERT INTO crm_currency (currency_code, name, is_base_currency) VALUES ('EUR', 'Euro', TRUE)");
|
||||
runChangelog(connection, true);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT currency_code FROM crm_currency")).isEqualTo("EUR");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_currency")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real thing: Liquibase applies the schema and then the defaults on H2;
|
||||
** a second update runs nothing; the rows are what the SQL-level tests saw.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void liquibaseAppliesTheDefaultsOnce() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE filename LIKE '%crm-defaults%' AND exectype = 'EXECUTED'")).isEqualTo("10");
|
||||
Map<String, Integer> first = counts(connection);
|
||||
assertThat(first).containsEntry("crm_pipeline", 1).containsEntry("crm_pipeline_stage", 6).containsEntry("crm_lifecycle_stage", 8);
|
||||
|
||||
connection.createStatement().execute("UPDATE crm_pipeline_stage SET probability_pct = 15 WHERE name = 'Qualification'");
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE filename LIKE '%crm-defaults%'")).isEqualTo("10");
|
||||
assertThat(counts(connection)).isEqualTo(first);
|
||||
assertThat(scalar(connection, "SELECT probability_pct FROM crm_pipeline_stage WHERE name = 'Qualification'")).isEqualTo("15");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that has not created the CRM tables yet does not fail: every
|
||||
** changeset is marked ran. A tenant-enabled schema is marked ran too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void liquibaseMarksRanWhenTablesAreMissingOrTenanted() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("10");
|
||||
}
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, TENANCY);
|
||||
liquibase(connection, CHANGELOG);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE filename LIKE '%crm-defaults%' AND exectype = 'MARK_RAN'")).isEqualTo("10");
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM " + table)).as(table).isEqualTo("0");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rolling the defaults back deletes nothing: host-owned rows (and tenant rows,
|
||||
** whose changesets were marked ran) survive a Liquibase rollback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rollbackNeverDeletesHostRows() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, CHANGELOG);
|
||||
Map<String, Integer> seeded = counts(connection);
|
||||
connection.createStatement().execute("INSERT INTO crm_lead_source (name, sort_order) VALUES ('Host source', 99)");
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default) VALUES ('Host pipeline', FALSE)");
|
||||
connection.commit();
|
||||
rollback(connection, CHANGELOG, 10);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source WHERE name = 'Host source'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE name = 'Host pipeline'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source")).isEqualTo("9");
|
||||
assertThat(seeded.get("crm_pipeline_stage")).isEqualTo(6);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).isEqualTo("6");
|
||||
}
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, TENANCY);
|
||||
liquibase(connection, CHANGELOG);
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default, tenant_key) VALUES ('Sales Pipeline', TRUE, 'acme')");
|
||||
connection.createStatement().execute("INSERT INTO crm_lifecycle_stage (name, sort_order, tenant_key) VALUES ('Lead', 1, 'acme')");
|
||||
connection.commit();
|
||||
rollback(connection, CHANGELOG, 10);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE tenant_key = 'acme'")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage WHERE tenant_key = 'acme'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle stages and the base currency change what intake, Convert lead
|
||||
** and amounts do, so they are seeded only on a fresh install; the pipeline
|
||||
** stages only into the pipeline the defaults created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void behaviorChangingSeedsNeedAFreshInstall() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
connection.createStatement().execute("INSERT INTO crm_contact (email) VALUES ('a@example.test')");
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lead_source")).isEqualTo("8");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_currency")).isEqualTo("1");
|
||||
}
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
connection.createStatement().execute("INSERT INTO crm_deal (name, pipeline_id, pipeline_stage_id) VALUES ('d', 1, 1)");
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_currency")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).isEqualTo("0");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_lifecycle_stage")).isEqualTo("8");
|
||||
}
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default) VALUES ('Sales Pipeline', TRUE)");
|
||||
connection.createStatement().execute("INSERT INTO crm_pipeline (name, is_default) VALUES ('Other', FALSE)");
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline_stage")).as("not the only pipeline").isEqualTo("0");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Order matters: included before crm-tenancy, the untenanted rows are seeded
|
||||
** and the tenancy columns are added afterwards; included after, nothing is.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void orderAgainstTenancyMatters() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(url()))
|
||||
{
|
||||
liquibase(connection, SCHEMA);
|
||||
liquibase(connection, CHANGELOG);
|
||||
liquibase(connection, TENANCY);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline WHERE tenant_key IS NULL")).as("wrong order: untenanted rows").isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void rollback(Connection connection, String path, int count) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(path.substring(1), new ClassLoaderResourceAccessor(), database).rollback(count, "crm-tenancy");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String url()
|
||||
{
|
||||
return ("jdbc:h2:mem:crmlb" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply one changelog (resource path with a leading slash) through Liquibase.
|
||||
*******************************************************************************/
|
||||
private static void liquibase(Connection connection, String path) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(path.substring(1), new ClassLoaderResourceAccessor(), database).update("crm-tenancy");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A tenant-enabled schema (tenant_key columns added) is skipped, honoring each
|
||||
** changeset's precondition: seeding without a tenant key would create rows no
|
||||
** tenant can see. A plain schema is not skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void skipsTenantEnabledTables() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements(resource(TENANCY)))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
runChangelog(connection, false);
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM " + table)).as(table).isEqualTo("0");
|
||||
}
|
||||
}
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection, false);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM crm_pipeline")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every changeset guards itself: a precondition (a host without the table
|
||||
** marks it ran), an insert that skips rows that exist, and a rollback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyChangesetIsGuarded() throws Exception
|
||||
{
|
||||
List<String> changesets = changesets(resource(CHANGELOG));
|
||||
assertThat(changesets).hasSizeGreaterThanOrEqualTo(SEEDED_TABLES.size());
|
||||
for(String changeset : changesets)
|
||||
{
|
||||
assertThat(changeset).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- rollback ").contains("NOT EXISTS")
|
||||
.contains("-- precondition-sql-check expectedResult:0 ").doesNotContain("UPDATE ");
|
||||
}
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(resource(CHANGELOG)).contains("INSERT INTO " + table + " ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh H2 (PostgreSQL mode) with the qbit's own schema.
|
||||
*******************************************************************************/
|
||||
public static Connection freshSchema() throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:crmdefaults" + System.nanoTime() + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH");
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements(resource(SCHEMA)))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run each changeset's statements. With ignorePreconditions, the SQL runs
|
||||
** whatever the preconditions say; otherwise a changeset whose
|
||||
** precondition-sql-check does not give its expected result is skipped, as
|
||||
** Liquibase would (MARK_RAN).
|
||||
*******************************************************************************/
|
||||
public static void runChangelog(Connection connection, boolean ignorePreconditions) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String changeset : changesets(resource(CHANGELOG)))
|
||||
{
|
||||
if(!ignorePreconditions && !preconditionsHold(connection, changeset))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(String sql : statements(changeset))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean preconditionsHold(Connection connection, String changeset) throws Exception
|
||||
{
|
||||
Matcher m = Pattern.compile("(?m)^-- precondition-sql-check expectedResult:(\\d+) (.*)$").matcher(changeset);
|
||||
while(m.find())
|
||||
{
|
||||
if(!scalar(connection, m.group(2)).equals(m.group(1)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String resource(String path) throws Exception
|
||||
{
|
||||
try(InputStream stream = CrmDefaultsChangelogTest.class.getResourceAsStream(path))
|
||||
{
|
||||
assertThat(stream).as(path + " is on the classpath").isNotNull();
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text of each changeset (from its "-- changeset" line).
|
||||
*******************************************************************************/
|
||||
static List<String> changesets(String changelog)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String part : changelog.split("(?m)^(?=-- changeset )"))
|
||||
{
|
||||
if(part.startsWith("-- changeset "))
|
||||
{
|
||||
rs.add(part);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL statements, without comment lines, split at a ';' that ends a line.
|
||||
*******************************************************************************/
|
||||
static List<String> statements(String sql)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(String line : sql.split("\\R"))
|
||||
{
|
||||
if(!line.strip().startsWith("--"))
|
||||
{
|
||||
text.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String statement : text.toString().split(";\\s*\\n"))
|
||||
{
|
||||
if(!statement.isBlank())
|
||||
{
|
||||
rs.add(statement.strip().replaceAll(";$", ""));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Integer> counts(Connection connection) throws Exception
|
||||
{
|
||||
Map<String, Integer> rs = new LinkedHashMap<>();
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
rs.put(table, Integer.valueOf(scalar(connection, "SELECT COUNT(*) FROM " + table)));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String scalar(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
Object value = rs.getObject(1);
|
||||
return (value instanceof BigDecimal bd ? bd.stripTrailingZeros().toPlainString() : String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.crm.help.CrmHelpBundleStandIn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: every table populated, used and
|
||||
** self-explaining). For every crm_* table:
|
||||
** - runtime code (not just its model, metadata or setup card) reads or writes
|
||||
** it, or the table is listed here as having no runtime code and why;
|
||||
** - it is either seeded by the shipped defaults changelog, or listed here
|
||||
** with why it starts empty;
|
||||
** - it has bundle help that says how its rows get created (shown on the table
|
||||
** screen and when it is empty).
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class CrmTablesGuardrailTest extends BaseTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "crm");
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata and presentation files: naming a table here is not using it.
|
||||
*******************************************************************************/
|
||||
private static final Set<String> NOT_RUNTIME = Set.of("CrmAppMetaData.java", "CrmPresentation.java", "CrmPermissions.java",
|
||||
"CrmDataPolicy.java", "CrmSetupStatus.java", "CrmIntegrations.java");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("crm_contact", "user content: people, imports, converted web leads"),
|
||||
Map.entry("crm_company", "user content"),
|
||||
Map.entry("crm_deal", "user content: New, or Create Deal on a contact"),
|
||||
Map.entry("crm_deal_contact", "user content: Add Contact on a deal"),
|
||||
Map.entry("crm_deal_product", "user content: product lines on a deal"),
|
||||
Map.entry("crm_deal_stage_history", "runtime: a deal is created or changes stage"),
|
||||
Map.entry("crm_activity", "user content: Log Activity"),
|
||||
Map.entry("crm_activity_participant", "user content: people added to an activity"),
|
||||
Map.entry("crm_form_submission", "runtime: the host's public intake call"),
|
||||
Map.entry("crm_consent_record", "runtime: evidence written when a consent decision is made"),
|
||||
Map.entry("crm_email_list", "user content: lists are the business's own"),
|
||||
Map.entry("crm_email_list_member", "runtime: subscribing a contact or address"),
|
||||
Map.entry("crm_email_message", "runtime: every email sent or received"),
|
||||
Map.entry("crm_email_template", "user content: the wording is the business's own"),
|
||||
Map.entry("crm_email_sequence", "user content: the outreach is the business's own"),
|
||||
Map.entry("crm_sequence_step", "user content: steps of a sequence"),
|
||||
Map.entry("crm_sequence_enrollment", "runtime: Enroll in Sequence"),
|
||||
Map.entry("crm_product", "user content: the catalogue is the business's own"),
|
||||
Map.entry("crm_sales_goal", "user content: targets per user and period"),
|
||||
Map.entry("crm_lead_score_rule", "business-specific: good scoring depends on the business"),
|
||||
Map.entry("crm_assignment_rule", "host-specific: rules name the host's users"),
|
||||
Map.entry("crm_assignment_rule_member", "host-specific: the host's users"),
|
||||
Map.entry("crm_job_state", "runtime: a scheduled maintenance run saves its checkpoint"),
|
||||
Map.entry("crm_external_mapping", "not wired: nothing in the CRM writes it (reserved for a host integration)"));
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables no CRM code reads or writes: their rows are the user's own, added
|
||||
** and edited through the standard table screens and the published API.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> USER_OWNED_NO_CODE = Map.of(
|
||||
"crm_activity_participant", "user-owned child rows added on an activity's record",
|
||||
"crm_product", "user-owned catalogue; deal product lines pick from it (possible values, line item screens)",
|
||||
"crm_industry", "reference list consumed only through the Industry picker on a company (possible values)");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables with no writer in the CRM; their help must say so.
|
||||
*******************************************************************************/
|
||||
static final Set<String> NOT_WRITTEN_YET = Set.of("crm_external_mapping");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java")).filter(p -> !p.toString().replace('\\', '/').contains("/model/"))
|
||||
.filter(p -> !NOT_RUNTIME.contains(p.getFileName().toString())).map(CrmTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : crmTables())
|
||||
{
|
||||
if(USER_OWNED_NO_CODE.containsKey(table))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String entity = entityByTable.get(table);
|
||||
Pattern entityUse = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
|
||||
assertThat(entity).as("entity class for " + table).isNotNull();
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> source.contains("\"" + table + "\"") || entityUse.matcher(source).find()))
|
||||
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
String changelog = CrmDefaultsChangelogTest.resource(CrmDefaultsChangelogTest.CHANGELOG);
|
||||
for(String table : crmTables())
|
||||
{
|
||||
boolean seeded = CrmDefaultsChangelogTest.SEEDED_TABLES.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance().getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(CrmHelpBundleStandIn.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : crmTables())
|
||||
{
|
||||
List<QHelpContent> help = qInstance().getTable(table).getHelpContent() == null ? null : qInstance().getTable(table).getHelpContent().get("help");
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("**How rows get created:**");
|
||||
}
|
||||
for(String table : NOT_WRITTEN_YET)
|
||||
{
|
||||
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table + " says it is not used yet").contains("not used yet");
|
||||
}
|
||||
assertThat(qInstance().getApp("crm").getHelpContent()).containsKey("help");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables seeded by the changelog are the ones the help says are seeded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpOfSeededTablesSaysSo() throws Exception
|
||||
{
|
||||
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
|
||||
{
|
||||
assertThat(qInstance().getTable(table).getHelpContent().get("help").get(0).getContent()).as(table).contains("seeds");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance()
|
||||
{
|
||||
return (com.kingsrook.qqq.backend.core.context.QContext.getQInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> crmTables()
|
||||
{
|
||||
List<String> tables = qInstance().getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("crm_")).sorted().toList();
|
||||
assertThat(tables).hasSize(34);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** model/X.java declares TABLE_NAME = "x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> entityByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
for(Path file : files.filter(f -> f.toString().endsWith(".java")).toList())
|
||||
{
|
||||
Matcher m = p.matcher(read(file));
|
||||
if(m.find())
|
||||
{
|
||||
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qbits.crm.CrmDefaultsChangelogTest;
|
||||
import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.widgets.CrmSetupStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped defaults change behavior: with exactly the rows the changelog
|
||||
** inserts (applied to H2, then copied into the test backend), Convert lead
|
||||
** works and moves the contact to Opportunity, intake gives a new lead the Lead
|
||||
** stage, and the Setup card has nothing left to do.
|
||||
*******************************************************************************/
|
||||
class CrmDefaultsBehaviorTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsMakeTheCrmWork() throws Exception
|
||||
{
|
||||
loadSeededRows();
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// intake: a new web lead is assigned the seeded "Lead" stage //
|
||||
////////////////////////////////////////////////////////////////
|
||||
CrmIntakeApi intake = new CrmIntakeApi(CrmApi.config(), new CrmEmailTransport(CrmApi.config()));
|
||||
Integer contactId = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, "Ada", "Lovelace", "ada@example.test", "555-0100",
|
||||
"Example", "Hello", false, "Reply", "search", "organic", "c", "banner", "crm", "https://example.test/form", "https://search.example.test",
|
||||
"a".repeat(64), "b".repeat(64))).contactId();
|
||||
QRecord lead = CrmStore.asUser().get("crm_contact", contactId);
|
||||
Integer leadStageId = CrmStore.asUser().by("crm_lifecycle_stage", "name", "Lead").getFirst().getValueInteger("id");
|
||||
assertThat(lead.getValueInteger("lifecycleStageId")).isEqualTo(leadStageId);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Convert lead: finds the default pipeline and its first stage, and moves the //
|
||||
// contact to the seeded "Opportunity" stage //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
RunBackendStepInput input = new RunBackendStepInput();
|
||||
input.setProcessName(ConvertLeadProcess.NAME);
|
||||
input.addValue("contactId", contactId);
|
||||
input.addValue("dealName", "Ada deal");
|
||||
input.addValue("dealAmount", new BigDecimal("1000"));
|
||||
RunBackendStepOutput output = runBusinessStep(input);
|
||||
QRecord deal = CrmStore.asUser().get("crm_deal", output.getValueInteger("dealId"));
|
||||
assertThat(deal).isNotNull();
|
||||
QRecord firstStage = CrmStore.asUser().by("crm_pipeline_stage", "name", "Qualification").getFirst();
|
||||
assertThat(deal.getValueInteger("pipelineStageId")).isEqualTo(firstStage.getValueInteger("id"));
|
||||
Integer opportunityId = CrmStore.asUser().by("crm_lifecycle_stage", "name", "Opportunity").getFirst().getValueInteger("id");
|
||||
assertThat(CrmStore.asUser().get("crm_contact", contactId).getValueInteger("lifecycleStageId")).isEqualTo(opportunityId);
|
||||
assertThat(deal.getValueBigDecimal("amountInBaseCurrency")).isEqualByComparingTo("1000");
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// setup card: nothing left to do for the five tracked items //
|
||||
///////////////////////////////////////////////////////////////
|
||||
List<CrmSetupStatus.Item> items = CrmSetupStatus.load();
|
||||
assertThat(items).extracting(CrmSetupStatus.Item::state).doesNotContain(CrmSetupStatus.State.TODO);
|
||||
assertThat(items).filteredOn(item -> !item.label().equals("Email delivery")).extracting(CrmSetupStatus.Item::state).containsOnly(CrmSetupStatus.State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the defaults to a fresh H2 schema and insert every row it created
|
||||
** into the test backend, preserving ids.
|
||||
*******************************************************************************/
|
||||
private void loadSeededRows() throws Exception
|
||||
{
|
||||
try(Connection connection = CrmDefaultsChangelogTest.freshSchema())
|
||||
{
|
||||
CrmDefaultsChangelogTest.runChangelog(connection, false);
|
||||
for(String table : CrmDefaultsChangelogTest.SEEDED_TABLES)
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT * FROM " + table + " ORDER BY id"))
|
||||
{
|
||||
ResultSetMetaData meta = rs.getMetaData();
|
||||
while(rs.next())
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
for(int i = 1; i <= meta.getColumnCount(); i++)
|
||||
{
|
||||
String column = meta.getColumnLabel(i).toLowerCase();
|
||||
if(column.equals("create_date") || column.equals("modify_date") || rs.getObject(i) == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
record.setValue(camel(column), (java.io.Serializable) rs.getObject(i));
|
||||
}
|
||||
var result = new InsertAction().execute(new InsertInput(table).withRecord(record));
|
||||
assertThat(result.getRecords().getFirst().getErrors()).as(table).isNullOrEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String camel(String snake)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean upper = false;
|
||||
for(char c : snake.toCharArray())
|
||||
{
|
||||
if(c == '_')
|
||||
{
|
||||
upper = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append(upper ? Character.toUpperCase(c) : c);
|
||||
upper = false;
|
||||
}
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.core.model;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** crm_currency.rateLastUpdatedDate says when the exchange rate was last set or
|
||||
** changed: stamped on insert, moved by a changed rate, left alone by an edit
|
||||
** that does not touch the rate.
|
||||
*******************************************************************************/
|
||||
class CurrencyRateDateTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stampsOnInsertAndOnlyMovesWhenTheRateChanges() throws Exception
|
||||
{
|
||||
Instant before = Instant.now().minusSeconds(1);
|
||||
Integer id = new InsertAction().execute(new InsertInput("crm_currency").withRecord(new QRecord()
|
||||
.withValue("currencyCode", "EUR").withValue("name", "Euro").withValue("exchangeRateToBase", new BigDecimal("1.10"))))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
Instant stamped = GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate");
|
||||
assertThat(stamped).as("insert stamps the date").isAfter(before);
|
||||
|
||||
Thread.sleep(20);
|
||||
new UpdateAction().execute(new UpdateInput("crm_currency").withRecord(new QRecord().withValue("id", id).withValue("name", "Euro (EU)")));
|
||||
assertThat(GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate")).as("a rename leaves the date").isEqualTo(stamped);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput("crm_currency").withRecord(new QRecord().withValue("id", id).withValue("exchangeRateToBase", new BigDecimal("1.100000"))));
|
||||
assertThat(GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate")).as("the same rate leaves the date").isEqualTo(stamped);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput("crm_currency").withRecord(new QRecord().withValue("id", id).withValue("exchangeRateToBase", new BigDecimal("1.25"))));
|
||||
assertThat(GetAction.execute("crm_currency", id).getValueInstant("rateLastUpdatedDate")).as("a new rate moves the date").isAfter(stamped);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -140,7 +140,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
{
|
||||
assertStatistic(name, "0");
|
||||
}
|
||||
for(String name : CrmHomeWidgets.HOME.subList(1, CrmHomeWidgets.HOME.size()))
|
||||
for(String name : CrmHomeWidgets.HOME.subList(1, CrmHomeWidgets.HOME.size()).stream().filter(name -> !name.equals("crm_setup")).toList())
|
||||
{
|
||||
TableData data = (TableData) render(name);
|
||||
assertThat(data.getRows()).hasSize(name.equals("crm_deal_forecast") ? 6 : 0);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.crm.BaseTest;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: what is missing is called out with its consequence, what is
|
||||
** in place is marked set up, and the card renders on the home.
|
||||
*******************************************************************************/
|
||||
class CrmSetupStatusTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Nothing configured: the four things the CRM needs are flagged.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyInstallNeedsSetup() throws Exception
|
||||
{
|
||||
Map<String, CrmSetupStatus.Item> items = items();
|
||||
assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency", "Email delivery");
|
||||
assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails");
|
||||
assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity");
|
||||
assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items.get("Email delivery").state()).isNotEqualTo(CrmSetupStatus.State.TODO);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A default pipeline without a closed-won or closed-lost stage still needs setup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pipelineNeedsClosedStages() throws Exception
|
||||
{
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true));
|
||||
assertThat(items().get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.OK);
|
||||
assertThat(items().get("Pipeline stages").detail()).contains("no stages");
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 1).withValue("pipelineId", 1).withValue("name", "Open").withValue("sortOrder", 1));
|
||||
assertThat(items().get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
assertThat(items().get("Pipeline stages").detail()).contains("no Closed Won");
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true));
|
||||
assertThat(items().get("Pipeline stages").detail()).contains("no Closed Lost");
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true));
|
||||
assertThat(items().get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle stages name exactly what is missing; currency needs a base.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lifecycleAndCurrency() throws Exception
|
||||
{
|
||||
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead"));
|
||||
assertThat(items().get("Lifecycle stages").detail()).contains("Missing Opportunity").doesNotContain("web leads");
|
||||
assertThat(items().get("Lifecycle stages").nextStep()).contains("\"Opportunity\"");
|
||||
CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity"));
|
||||
assertThat(items().get("Lifecycle stages").state()).isEqualTo(CrmSetupStatus.State.OK);
|
||||
|
||||
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "EUR").withValue("isBaseCurrency", false));
|
||||
assertThat(items().get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO);
|
||||
CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
|
||||
assertThat(items().get("Base currency").detail()).startsWith("USD");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The card renders on the home with one linked row per item.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rendersOnTheHome() throws Exception
|
||||
{
|
||||
assertThat(CrmHomeWidgets.HOME).contains("crm_setup");
|
||||
TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup");
|
||||
assertThat(data.getRows()).hasSize(5);
|
||||
CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline");
|
||||
assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, CrmSetupStatus.Item> items() throws Exception
|
||||
{
|
||||
List<CrmSetupStatus.Item> items = CrmSetupStatus.load();
|
||||
return (items.stream().collect(Collectors.toMap(CrmSetupStatus.Item::label, item -> item, (a, b) -> a, java.util.LinkedHashMap::new)));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user