diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java index 1859f12..5ba4d00 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java @@ -76,6 +76,11 @@ public class CrmHomeWidgets extends MetaDataProducer item.state() == State.TODO)); + } + + + /******************************************************************************* ** Build the items for the current user. *******************************************************************************/ @@ -160,14 +168,6 @@ public final class CrmSetupStatus ? new Item("Base currency", "crm_currency", State.TODO, "None: amounts have no currency prefix and conversions do nothing.", "Add your currency with Base currency ticked and rate 1.") : new Item("Base currency", "crm_currency", State.OK, base.getFirst().getValueString("currencyCode") + " is the base currency.", null)); - /////////////////////////////////////////////////////// - // email: sequences and list mail need a delivery path // - /////////////////////////////////////////////////////// - CrmReadiness readiness = CrmApi.readiness(); - items.add(readiness == CrmReadiness.NOT_CONFIGURED - ? new Item("Email delivery", "crm_email_template", State.OPTIONAL, "No email path: sequences and list email cannot send.", - "Install and configure the email qbit, or set a messaging provider on the CRM configuration.") - : new Item("Email delivery", "crm_email_template", State.OK, readiness == CrmReadiness.QBIT_EMAIL ? "Sends through the email qbit." : "Sends through core messaging.", null)); return (items); } diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSetupVisibility.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSetupVisibility.java new file mode 100644 index 0000000..935a609 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSetupVisibility.java @@ -0,0 +1,64 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm.widgets; + + +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; + + +/******************************************************************************* + ** Show the Setup card only to CRM settings holders (crm.manage) and only while + ** a business setup step is still missing. Evaluated at request time, so a + ** completed setup drops the card from per-session metadata and from render. + *******************************************************************************/ +public class CrmSetupVisibility implements CustomPermissionChecker +{ + /******************************************************************************* + ** Require crm.hasAccess and crm.manage.hasAccess, then an incomplete setup. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS) || !QContext.getQSession().hasPermission(CrmPermissions.MANAGE)) + { + throw (new QPermissionDeniedException("CRM settings access is required.")); + } + try + { + if(CrmSetupStatus.isIncomplete()) + { + return; + } + } + catch(QException e) + { + throw (new QPermissionDeniedException("Setup status is not available.", e)); + } + throw (new QPermissionDeniedException("CRM setup is complete.")); + } +} diff --git a/src/main/resources/help/qbit-crm.yaml b/src/main/resources/help/qbit-crm.yaml index ea01fd6..8ed3c0c 100644 --- a/src/main/resources/help/qbit-crm.yaml +++ b/src/main/resources/help/qbit-crm.yaml @@ -5,7 +5,7 @@ entries: - key: app:crm;slot:help summary: "Manage relationships, sales opportunities and consent." content: | - Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. The Setup card on this page shows what still needs configuring: the default pipeline and its stages, lifecycle stages, the base currency and email delivery. Ask your administrator which reference data and base currency are configured for your installation. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport. + Use **Contacts**, **Companies**, **Deals**, **Activities**, **Web Leads** and **Lists** for daily work. The Setup card on this page shows what still needs configuring: the default pipeline and its stages, lifecycle stages, and the base currency. It only appears for CRM settings users, and only while a step is missing. Ask your administrator which reference data and base currency are configured for your installation. Configure pipelines, outcomes and outreach in Settings. Marketing email requires recorded consent and a ready email transport. - key: table:crm_job_state;slot:help summary: "Internal maintenance checkpoints." content: | diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmSetupStatusTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmSetupStatusTest.java index c999865..eec87ed 100644 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmSetupStatusTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmSetupStatusTest.java @@ -23,12 +23,21 @@ package com.kingsrook.qbits.crm.widgets; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; import com.kingsrook.qqq.backend.core.model.data.QRecord; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /******************************************************************************* @@ -38,19 +47,18 @@ import static org.assertj.core.api.Assertions.assertThat; class CrmSetupStatusTest extends BaseTest { /******************************************************************************* - ** Nothing configured: the four things the CRM needs are flagged. + ** Nothing configured: the business setup the CRM needs are flagged. *******************************************************************************/ @Test void emptyInstallNeedsSetup() throws Exception { Map items = items(); - assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency", "Email delivery"); + assertThat(items).containsOnlyKeys("Default pipeline", "Pipeline stages", "Lifecycle stages", "Base currency"); assertThat(items.get("Default pipeline").state()).isEqualTo(CrmSetupStatus.State.TODO); assertThat(items.get("Default pipeline").detail()).contains("Convert lead fails"); assertThat(items.get("Pipeline stages").state()).isEqualTo(CrmSetupStatus.State.TODO); assertThat(items.get("Lifecycle stages").detail()).contains("Lead and Opportunity"); assertThat(items.get("Base currency").state()).isEqualTo(CrmSetupStatus.State.TODO); - assertThat(items.get("Email delivery").state()).isNotEqualTo(CrmSetupStatus.State.TODO); } @@ -106,7 +114,7 @@ class CrmSetupStatusTest extends BaseTest { assertThat(CrmHomeWidgets.HOME).contains("crm_setup"); TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup"); - assertThat(data.getRows()).hasSize(5); + assertThat(data.getRows()).hasSize(4); CrmHomeWidgetsTest.assertCell(data, 0, "first", "Default pipeline"); assertThat(data.getRows().get(0).get("second").toString()).contains("Needs setup"); @@ -114,6 +122,76 @@ class CrmSetupStatusTest extends BaseTest + /******************************************************************************* + ** The checklist is business setup only: no email-provider row. + *******************************************************************************/ + @Test + void noEmailProviderRow() throws Exception + { + assertThat(items().keySet()).noneMatch(label -> label.toLowerCase().contains("email")); + TableData data = (TableData) CrmHomeWidgetsTest.render("crm_setup"); + assertThat(data.getRows().toString()).doesNotContain("email qbit").doesNotContain("Email delivery"); + } + + + + /******************************************************************************* + ** Settings holders get the card while setup is incomplete, and not once complete. + *******************************************************************************/ + @Test + void settingsHolderSeesCardOnlyWhileIncomplete() throws Exception + { + assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isTrue(); + + completeSetup(); + assertThat(CrmSetupStatus.isIncomplete()).isFalse(); + assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse(); + assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class); + } + + + + /******************************************************************************* + ** A CRM user without the settings permission never gets the card, incomplete or not. + *******************************************************************************/ + @Test + void nonSettingsUserNeverSeesCard() throws Exception + { + QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE)); + assertThat(CrmSetupStatus.isIncomplete()).isTrue(); + assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_setup")).isFalse(); + assertThatThrownBy(() -> renderEnforced()).isInstanceOf(QPermissionDeniedException.class); + } + + + + /******************************************************************************* + ** Render the setup widget the way the UI API does, with permissions enforced. + *******************************************************************************/ + private void renderEnforced() throws Exception + { + RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_setup")); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + new RenderWidgetAction().execute(input); + } + + + + /******************************************************************************* + ** Seed everything the checklist asks for. + *******************************************************************************/ + private void completeSetup() throws Exception + { + CrmHomeWidgetsTest.seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true)); + CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Won").withValue("sortOrder", 2).withValue("isClosedWon", true)); + CrmHomeWidgetsTest.seed("crm_pipeline_stage", new QRecord().withValue("id", 3).withValue("pipelineId", 1).withValue("name", "Lost").withValue("sortOrder", 3).withValue("isClosedLost", true)); + CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 1).withValue("name", "Lead")); + CrmHomeWidgetsTest.seed("crm_lifecycle_stage", new QRecord().withValue("id", 2).withValue("name", "Opportunity")); + CrmHomeWidgetsTest.seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true)); + } + + + /******************************************************************************* ** *******************************************************************************/