From 15a2549b59e46a6b466cdecbdaebbadbc065f9c8 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:00:38 -0500 Subject: [PATCH 1/2] feat(actions): CRM record actions on the standard streamed ETL with review and result summaries Co-Authored-By: Claude Opus 5.5 --- .../qbits/crm/actions/CrmAction.java | 635 +++++++++++++++++ .../qbits/crm/actions/CrmActions.java | 195 ++++++ .../qbits/crm/actions/CrmActionsTest.java | 662 ++++++++++++++++++ 3 files changed, 1492 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java create mode 100644 src/main/java/com/kingsrook/qbits/crm/actions/CrmActions.java create mode 100644 src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java b/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java new file mode 100644 index 0000000..5f82fd9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/actions/CrmAction.java @@ -0,0 +1,635 @@ +/* + * 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.actions; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess; +import com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess; +import com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess; +import com.kingsrook.qbits.crm.core.processes.MergeContactsProcess; +import com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess; +import com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess; +import com.kingsrook.qbits.crm.deals.processes.CloneDealProcess; +import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess; +import com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess; +import com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess; +import com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess; +import com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess; +import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +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.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Shared streamed action lifecycle. Review never invokes a mutating operation. + *******************************************************************************/ +public final class CrmAction +{ + private static final QLogger LOG = QLogger.getLogger(CrmAction.class); + + public static final String EMAIL_WARNING = "Email sending arrives with the email transport"; + + /******************************************************************************* + ** Utility holder. + *******************************************************************************/ + private CrmAction() + { + } + + /******************************************************************************* + ** Pin extraction to metadata and require an explicit record selection/filter. + *******************************************************************************/ + public static class Extract extends ExtractViaQueryStep + { + @Override + public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String table = CrmActions.action(input.getProcessName()).table(); + input.addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table); + input.addValue(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table); + super.preRun(input, output); + } + + /******************************************************************************* + ** Reuse the core selection parser for preparation and merge validation. + *******************************************************************************/ + QQueryFilter selection(RunBackendStepInput input) throws QException + { + if(!StringUtils.hasContent(input.getValueString("queryFilterJson")) + && (input.getCallback() == null || input.getCallback().getQueryFilter() == null)) + { + throw (new QUserFacingException("Select records before running this action")); + } + return (super.getQueryFilter(input).clone()); + } + + @Override + protected QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException + { + return (selection(input)); + } + } + + /******************************************************************************* + ** Populate read-only pipeline context and the merge survivor dropdown. + *******************************************************************************/ + public static class Prepare implements BackendStep + { + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + CrmActions.Action action = CrmActions.action(input.getProcessName()); + QQueryFilter filter = new Extract().selection(input); + if(action.operation().equals("merge")) + { + List rows = selectedPair(action, input); + output.addValue("mergeIds", new ArrayList<>(rows.stream().map(r -> r.getValue("id")).toList())); + output.addValue("primaryId", rows.getFirst().getValue("id")); + } + else + { + List rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter.withLimit(2)) + .withFieldNamesToInclude(Set.of("pipelineId")).withSelectDistinct(true)).getRecords(); + List pipelines = rows.stream().map(r -> r.getValue("pipelineId")).distinct().toList(); + if(pipelines.size() != 1) + { + throw (new QUserFacingException("Select deals from one pipeline to choose a target stage")); + } + output.addValue("pipelineId", pipelines.getFirst()); + } + } + } + + /******************************************************************************* + ** Validate and describe each selected row, preserving keys for standard links. + *******************************************************************************/ + public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final Map lines = new LinkedHashMap<>(); + private final Map> duplicateGroups = new LinkedHashMap<>(); + private List pair; + + @Override + public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + CrmActions.Action action = CrmActions.action(input.getProcessName()); + if(action.operation().equals("merge")) + { + pair = selectedPair(action, input); + if(pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), input.getValue("primaryId")))) + { + throw (new QUserFacingException("The record to keep must be one of the two selected records")); + } + } + } + + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + CrmActions.Action action = CrmActions.action(input.getProcessName()); + for(QRecord extracted : input.getRecords()) + { + Serializable id = extracted.getValue("id"); + try + { + QRecord row = requireRow(action, input, id); + String reason = check(action, input, row); + if(reason != null) + { + add(lines, Status.WARNING, reason, reason, id); + continue; + } + if(action.operation().equals("merge")) + { + if(!Objects.equals(id, input.getValue("primaryId"))) + { + continue; + } + Serializable secondary = pair.stream().map(r -> r.getValue("id")).filter(key -> !key.equals(id)).findFirst().orElseThrow(); + requireRow(action, input, secondary); + row.setValue("mergeSecondaryId", secondary); + } + if(action.operation().equals("duplicates")) + { + String key = row.getValueString(action.table().equals("crm_contact") ? "email" : "domain"); + if(StringUtils.hasContent(key)) + { + duplicateGroups.computeIfAbsent(key.toLowerCase(Locale.ROOT), ignored -> new ArrayList<>()).add(id); + } + } + String future = action.operation().equals("auto") ? "will be evaluated for assignment" : "will be " + action.past(); + add(lines, Status.OK, future, "was " + action.past(), id); + output.addRecord(row); + } + catch(QUserFacingException e) + { + add(lines, Status.WARNING, e.getMessage(), e.getMessage(), id); + } + catch(QException | RuntimeException e) + { + LOG.warn("CRM action validation failed", e); + add(lines, Status.ERROR, "cannot be processed; validation failed (see server log)", "was not processed; validation failed (see server log)", id); + } + } + } + + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean result) + { + ArrayList summary = new ArrayList<>(lines.values()); + int group = 0; + for(List ids : duplicateGroups.values()) + { + if(ids.size() > 1) + { + ProcessSummaryLine line = line(Status.WARNING, "will appear in duplicate group " + (++group), "appeared in duplicate group " + group); + ids.forEach(line::incrementCountAndAddPrimaryKey); + summary.add(line); + } + } + return (summary); + } + } + + /******************************************************************************* + ** Reauthorize and validate at execution time; count only actual outcomes. + *******************************************************************************/ + public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final Map lines = new LinkedHashMap<>(); + private boolean duplicates; + + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + CrmActions.Action action = CrmActions.action(input.getProcessName()); + duplicates = action.operation().equals("duplicates"); + for(QRecord reviewed : input.getRecords()) + { + Serializable id = reviewed.getValue("id"); + try + { + QRecord row = requireRow(action, input, id); + String reason = check(action, input, row); + if(reason != null) + { + add(lines, Status.WARNING, reason, reason, id); + continue; + } + row.setValue("mergeSecondaryId", reviewed.getValue("mergeSecondaryId")); + String skipped = perform(action, input, row); + if(skipped == null) + { + add(lines, Status.OK, "will be " + action.past(), "was " + action.past(), id); + } + else + { + add(lines, Status.WARNING, skipped, skipped, id); + } + } + catch(QException | RuntimeException e) + { + LOG.warn("CRM action execution failed", e); + String reason = e instanceof QUserFacingException ? e.getMessage() : "see server log"; + add(lines, Status.ERROR, "cannot be processed: " + reason, "failed: " + reason, id); + } + } + } + + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean result) + { + ArrayList summary = new ArrayList<>(lines.values()); + if(getTransformStep() instanceof Transform transform) + { + if(duplicates) + { + return (transform.getProcessSummary(output, result)); + } + transform.lines.values().stream().filter(line -> line.getStatus() != Status.OK).forEach(summary::add); + } + return (summary); + } + } + + /******************************************************************************* + ** Read current records under the session; read access alone cannot mutate. + *******************************************************************************/ + private static QRecord requireRow(CrmActions.Action action, RunBackendStepInput input, Serializable id) throws QException + { + TablePermissionSubType permission = action.operation().equals("duplicates") ? TablePermissionSubType.READ : TablePermissionSubType.EDIT; + if(!PermissionsHelper.hasProcessPermission(input, action.name()) || !PermissionsHelper.hasTablePermission(input, action.table(), permission)) + { + throw (new QUserFacingException("Permission denied for this action")); + } + QRecord row = GetAction.execute(action.table(), id); + if(row == null) + { + throw (new QUserFacingException("Record is unavailable")); + } + return (row); + } + + /******************************************************************************* + ** Merge only an explicit pair, including when called outside the UI. + *******************************************************************************/ + private static List selectedPair(CrmActions.Action action, RunBackendStepInput input) throws QException + { + QQueryFilter filter = new Extract().selection(input); + filter.setLimit(3); + List rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter)).getRecords(); + if(rows.size() != 2) + { + throw (new QUserFacingException("Select exactly two records to merge")); + } + return (rows); + } + + /******************************************************************************* + ** Shared read-only checks for Review and Result. + *******************************************************************************/ + private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException + { + for(QFieldMetaData field : CrmActions.inputFields(action)) + { + Serializable value = input.getValue(field.getName()); + if(Boolean.TRUE.equals(field.getIsRequired()) && (value == null || value instanceof String text && text.isBlank())) + { + return ("Supply " + field.getLabel() + " before reviewing"); + } + if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId")) + { + if(GetAction.execute(field.getPossibleValueSourceName(), value) == null) + { + return (field.getLabel() + " is unavailable"); + } + } + } + switch(action.operation()) + { + case "email": + try + { + Class.forName("com.kingsrook.qbits.crm.email.CrmEmailTransport"); + return ("Email transport is present; sending integration is not configured for this action yet"); + } + catch(ClassNotFoundException expected) + { + return (EMAIL_WARNING); + } + case "subscribe", "unsubscribe": + if(!StringUtils.hasContent(row.getValueString("email"))) + { + return ("Contact has no email address"); + } + break; + case "enroll": + if(Boolean.TRUE.equals(row.getValueBoolean("doNotEmail"))) + { + return ("Contact has opted out of email"); + } + if(!Boolean.TRUE.equals(GetAction.execute("crm_email_sequence", input.getValue("sequenceId")).getValueBoolean("isActive"))) + { + return ("Sequence is not active"); + } + if(!enrollments(input, row).isEmpty()) + { + return ("Contact is already enrolled in this sequence"); + } + break; + case "unenroll": + if(enrollments(input, row).isEmpty()) + { + return ("Contact has no active enrollment in this sequence"); + } + break; + case "stage", "won", "lost", "reopen": + QRecord target = targetStage(action, input, row); + if(target == null) + { + return ("No matching target stage exists in the deal's pipeline"); + } + if(!Objects.equals(row.getValue("pipelineId"), target.getValue("pipelineId"))) + { + return ("Target stage does not belong to this deal's pipeline"); + } + if(Objects.equals(row.getValue("pipelineStageId"), target.getValue("id"))) + { + return ("Deal is already in the target stage"); + } + if(action.operation().equals("reopen")) + { + QRecord current = GetAction.execute("crm_pipeline_stage", row.getValue("pipelineStageId")); + if(current == null || !closed(current) || closed(target)) + { + return ("Reopen requires a closed deal and an open target stage"); + } + } + if(StringUtils.hasContent(target.getValueString("requiredFieldsJson"))) + { + try + { + for(Object field : JsonUtils.toObject(target.getValueString("requiredFieldsJson"), List.class)) + { + if(row.getValue(field.toString()) == null) + { + return ("Required field '" + field + "' must be set before moving to this stage"); + } + } + } + catch(java.io.IOException e) + { + throw (new QException("Invalid stage required fields", e)); + } + } + break; + case "createDeal": + if(query("crm_pipeline_stage", "pipelineId", input.getValue("pipelineId")).isEmpty()) + { + return ("The pipeline has no stages"); + } + break; + default: + break; + } + return (null); + } + + /******************************************************************************* + ** Terminal stage lookup stays within each selected deal's pipeline. + *******************************************************************************/ + private static QRecord targetStage(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException + { + if(List.of("stage", "reopen").contains(action.operation())) + { + return (GetAction.execute("crm_pipeline_stage", input.getValue("targetStageId"))); + } + List stages = new QueryAction().execute(new QueryInput("crm_pipeline_stage").withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, row.getValue("pipelineId"))) + .withCriteria(new QFilterCriteria(action.operation().equals("won") ? "isClosedWon" : "isClosedLost", QCriteriaOperator.EQUALS, true)) + .withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")).withLimit(1))).getRecords(); + return (stages.isEmpty() ? null : stages.getFirst()); + } + + /******************************************************************************* + ** Closed-stage predicate. + *******************************************************************************/ + private static boolean closed(QRecord stage) + { + return (Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost"))); + } + + /******************************************************************************* + ** Invoke the existing business operations only at Load time. + *******************************************************************************/ + private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException + { + RunBackendStepInput call = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(new HashMap<>()); + for(QFieldMetaData field : CrmActions.inputFields(action)) + { + call.addValue(field.getName(), input.getValue(field.getName())); + } + Serializable id = row.getValue("id"); + call.addValue("contactId", action.table().equals("crm_contact") ? id : input.getValue("contactId")); + call.addValue("companyId", action.table().equals("crm_company") ? id : null); + call.addValue("dealId", action.table().equals("crm_deal") ? id : null); + call.addValue("formSubmissionId", id); + RunBackendStepOutput result = new RunBackendStepOutput(); + switch(action.operation()) + { + case "assign" -> update(action.table(), new QRecord().withValue("id", id).withValue("ownerUserId", input.getValue("newOwnerUserId"))); + case "auto" -> + { + call.addValue("entityType", "CONTACT"); + call.addValue("recordId", id); + new AutoAssignProcess().run(call, result); + if(!"ASSIGNED".equals(result.getValue("action"))) + { + return ("No assignment rule matched; owner unchanged"); + } + } + case "subscribe", "unsubscribe" -> + { + call.addValue("email", row.getValue("email")); + call.addValue("firstName", row.getValue("firstName")); + call.addValue("subscriptionSource", "CRM record action"); + if(action.operation().equals("subscribe")) + { + new SubscribeToListProcess().run(call, result); + } + else + { + new UnsubscribeFromListProcess().run(call, result); + } + if(List.of("NOOP", "NOT_FOUND").contains(result.getValue("action"))) + { + return ("List membership unchanged"); + } + } + case "enroll" -> new EnrollInSequenceProcess().run(call, result); + case "unenroll" -> + { + for(QRecord enrollment : enrollments(input, row)) + { + call.addValue("enrollmentId", enrollment.getValue("id")); + new UnenrollFromSequenceProcess().run(call, result); + } + } + case "activity" -> new LogActivityProcess().run(call, result); + case "score" -> new CalculateLeadScoresProcess().recalculate(row); + case "merge" -> + { + Serializable secondary = row.getValue("mergeSecondaryId"); + List pair = selectedPair(action, input); + if(secondary == null || secondary.equals(id) || pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), secondary))) + { + throw (new QUserFacingException("Merge selection changed; review it again")); + } + requireRow(action, input, secondary); + String entity = action.table().equals("crm_contact") ? "Contact" : "Company"; + call.addValue("primary" + entity + "Id", id); + call.addValue("secondary" + entity + "Id", secondary); + if(entity.equals("Contact")) + { + new MergeContactsProcess().run(call, result); + } + else + { + new MergeCompaniesProcess().run(call, result); + } + } + case "duplicates" -> + { + } + case "createDeal" -> new ConvertLeadProcess().run(call, result); + case "erase" -> new GdprAnonymizeContactProcess().run(call, result); + case "stage", "won", "lost", "reopen" -> + { + QRecord change = new QRecord().withValue("id", id).withValue("pipelineStageId", targetStage(action, input, row).getValue("id")); + if(List.of("won", "lost").contains(action.operation())) + { + change.setValue("winLossReasonId", input.getValue("winLossReasonId")); + } + update(action.table(), change); + } + case "clone" -> new CloneDealProcess().run(call, result); + case "addContact" -> + { + call.addValue("action", "ADD"); + new ManageContactOnDealProcess().run(call, result); + } + case "convert" -> new ConvertFormToContactProcess().run(call, result); + case "activate", "pause" -> update(action.table(), new QRecord().withValue("id", id).withValue("isActive", action.operation().equals("activate"))); + default -> throw (new QUserFacingException("Action is not available")); + } + return (null); + } + + /******************************************************************************* + ** Core DML can return record errors; never present those as successful writes. + *******************************************************************************/ + private static void update(String table, QRecord change) throws QException + { + List records = new UpdateAction().execute(new UpdateInput(table).withRecord(change)).getRecords(); + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + throw (new QUserFacingException("Update was rejected: " + record.getErrors())); + } + } + } + + /******************************************************************************* + ** Active enrollments belonging to the selected contact and chosen sequence. + *******************************************************************************/ + private static List enrollments(RunBackendStepInput input, QRecord row) throws QException + { + return (new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, row.getValue("id"))) + .withCriteria(new QFilterCriteria("sequenceId", QCriteriaOperator.EQUALS, input.getValue("sequenceId"))) + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))).getRecords()); + } + + /******************************************************************************* + ** Session-scoped related rows. + *******************************************************************************/ + private static List query(String table, String field, Serializable value) throws QException + { + return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)))).getRecords()); + } + + /******************************************************************************* + ** Standard future and past messages work on both Review and Result. + *******************************************************************************/ + private static ProcessSummaryLine line(Status status, String future, String past) + { + return (new ProcessSummaryLine(status).withSingularFutureMessage(future).withPluralFutureMessage(future) + .withSingularPastMessage(past).withPluralPastMessage(past.startsWith("was ") ? "were " + past.substring(4) : past)); + } + + /******************************************************************************* + ** Group equal outcomes and retain primary keys for standard record links. + *******************************************************************************/ + private static void add(Map lines, Status status, String future, String past, Serializable id) + { + lines.computeIfAbsent(status + ":" + future, ignored -> line(status, future, past)).incrementCountAndAddPrimaryKey(id); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/actions/CrmActions.java b/src/main/java/com/kingsrook/qbits/crm/actions/CrmActions.java new file mode 100644 index 0000000..ef5d273 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/actions/CrmActions.java @@ -0,0 +1,195 @@ +/* + * 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.actions; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + + +/******************************************************************************* + ** CRM record actions. The installed definition owns the table and operation. + *******************************************************************************/ +public class CrmActions implements MetaDataProducerInterface +{ + /******************************************************************************* + ** Immutable action definition; no session state is shared between runs. + *******************************************************************************/ + public record Action(String name, String table, String label, String operation, String past) + { + } + + public static final List ACTIONS = List.of( + new Action("crm_bulk_assign_owner", "crm_contact", "Assign Owner", "assign", "assigned a new owner"), + new Action("crm_auto_assign", "crm_contact", "Auto-Assign", "auto", "auto-assigned"), + new Action("crm_subscribe_to_list", "crm_contact", "Add to List", "subscribe", "added to the list"), + new Action("crm_unsubscribe_from_list", "crm_contact", "Remove from List", "unsubscribe", "removed from the list"), + new Action("crm_enroll_in_sequence", "crm_contact", "Enroll in Sequence", "enroll", "enrolled in the sequence"), + new Action("crm_unenroll_from_sequence", "crm_contact", "Unenroll from Sequence", "unenroll", "unenrolled from the sequence"), + new Action("crm_log_activity", "crm_contact", "Log Activity", "activity", "given a logged activity"), + new Action("crm_recalculate_score", "crm_contact", "Recalculate Score", "score", "rescored"), + new Action("crm_merge_contacts", "crm_contact", "Merge Contacts", "merge", "merged"), + new Action("crm_detect_duplicates", "crm_contact", "Find Duplicates", "duplicates", "checked for duplicates"), + new Action("crm_convert_lead", "crm_contact", "Create Deal", "createDeal", "given a new deal"), + new Action("crm_gdpr_anonymize_contact", "crm_contact", "Erase Personal Data", "erase", "anonymized"), + new Action("crm_send_email", "crm_contact", "Send Email", "email", "sent email"), + new Action("crm_assign_company_owner", "crm_company", "Assign Owner", "assign", "assigned a new owner"), + new Action("crm_merge_companies", "crm_company", "Merge Companies", "merge", "merged"), + new Action("crm_find_duplicate_companies", "crm_company", "Find Duplicates", "duplicates", "checked for duplicates"), + new Action("crm_log_company_activity", "crm_company", "Log Activity", "activity", "given a logged activity"), + new Action("crm_move_deal_stage", "crm_deal", "Move Stage", "stage", "moved to the target stage"), + new Action("crm_mark_deal_won", "crm_deal", "Mark Won", "won", "marked won"), + new Action("crm_mark_deal_lost", "crm_deal", "Mark Lost", "lost", "marked lost"), + new Action("crm_reopen_deal", "crm_deal", "Reopen", "reopen", "reopened"), + new Action("crm_clone_deal", "crm_deal", "Clone", "clone", "cloned"), + new Action("crm_transfer_ownership", "crm_deal", "Assign Owner", "assign", "assigned a new owner"), + new Action("crm_manage_contact_on_deal", "crm_deal", "Add Contact", "addContact", "linked to the contact"), + new Action("crm_log_deal_activity", "crm_deal", "Log Activity", "activity", "given a logged activity"), + new Action("crm_convert_form_to_contact", "crm_form_submission", "Convert to Contact", "convert", "converted to contacts"), + new Action("crm_activate_sequence", "crm_email_sequence", "Activate", "activate", "activated"), + new Action("crm_pause_sequence", "crm_email_sequence", "Pause", "pause", "paused")); + + /******************************************************************************* + ** Resolve exclusively by the installed process name, never client values. + *******************************************************************************/ + public static Action action(String name) + { + return (ACTIONS.stream().filter(a -> a.name().equals(name)).findFirst().orElseThrow()); + } + + /******************************************************************************* + ** Package-discovered metadata for every UI action. + *******************************************************************************/ + @Override + public MetaDataProducerMultiOutput produce(QInstance instance) + { + MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput(); + ACTIONS.forEach(action -> output.add(metadata(action))); + return (output); + } + + /******************************************************************************* + ** Input details precede the standard ETL Review and Result screens. + *******************************************************************************/ + public static QProcessMetaData metadata(Action action) + { + Map values = Map.of( + StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, action.table(), + StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, action.table(), + StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true, + StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true, + StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT, + StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, action.label() + " for the selected records."); + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(CrmAction.Extract.class, CrmAction.Transform.class, CrmAction.Load.class, values) + .withName(action.name()).withLabel(action.label()).withTableName(action.table()).withMinInputRecords(1) + .withIcon(new QIcon("play_arrow")).withPermissionRules(CrmPermissions.processRules(action.name())); + if(action.operation().equals("merge")) + { + process.withMinInputRecords(2).withMaxInputRecords(2); + } + List fields = inputFields(action); + if(!fields.isEmpty()) + { + QFrontendStepMetaData details = new QFrontendStepMetaData().withName("details").withLabel(action.label()) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)); + fields.forEach(details::withFormField); + process.withStep(0, details); + if(List.of("stage", "reopen", "merge").contains(action.operation())) + { + process.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(CrmAction.Prepare.class))); + } + } + return (process); + } + + /******************************************************************************* + ** Required user input, including server-validated contingent stage choices. + *******************************************************************************/ + public static List inputFields(Action action) + { + List fields = new ArrayList<>(); + switch(action.operation()) + { + case "assign" -> fields.add(field("newOwnerUserId", "Owner", QFieldType.STRING, null)); + case "subscribe", "unsubscribe" -> fields.add(field("emailListId", "List", QFieldType.INTEGER, "crm_email_list")); + case "enroll", "unenroll" -> fields.add(field("sequenceId", "Sequence", QFieldType.INTEGER, "crm_email_sequence")); + case "merge" -> fields.add(field("primaryId", "Record to Keep", QFieldType.INTEGER, action.table()) + .withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, "${input.mergeIds}")))); + case "stage", "reopen" -> + { + fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline").withIsEditable(false)); + fields.add(field("targetStageId", "Target Stage", QFieldType.INTEGER, "crm_pipeline_stage") + .withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}")))); + } + case "won", "lost" -> fields.add(field("winLossReasonId", "Win/Loss Reason", QFieldType.INTEGER, "crm_win_loss_reason")); + case "createDeal" -> + { + fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline")); + fields.add(field("dealName", "Deal Name", QFieldType.STRING, null)); + fields.add(field("dealAmount", "Amount", QFieldType.DECIMAL, null).withIsRequired(false)); + } + case "addContact" -> + { + fields.add(field("contactId", "Contact", QFieldType.INTEGER, "crm_contact")); + fields.add(field("contactRoleId", "Role", QFieldType.INTEGER, "crm_contact_role").withIsRequired(false)); + fields.add(field("isPrimary", "Primary Contact", QFieldType.BOOLEAN, null).withIsRequired(false)); + } + case "activity" -> + { + fields.add(field("activityTypeId", "Activity Type", QFieldType.INTEGER, "crm_activity_type")); + fields.add(field("subject", "Subject", QFieldType.STRING, null)); + fields.add(field("description", "Description", QFieldType.TEXT, null).withIsRequired(false)); + } + default -> + { + } + } + return (fields); + } + + /******************************************************************************* + ** Consistent required field metadata. + *******************************************************************************/ + private static QFieldMetaData field(String name, String label, QFieldType type, String pvs) + { + return (new QFieldMetaData(name, type).withLabel(label).withIsRequired(true).withPossibleValueSourceName(pvs)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java b/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java new file mode 100644 index 0000000..c72dff9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java @@ -0,0 +1,662 @@ +/* + * 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.actions; + + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Stream; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Every UI action traverses real ETL Review and Result against persisted data. + *******************************************************************************/ +class CrmActionsTest extends BaseTest +{ + /******************************************************************************* + ** Cover the complete registered action inventory. + *******************************************************************************/ + static Stream actions() + { + return (CrmActions.ACTIONS.stream().map(CrmActions.Action::name)); + } + + /******************************************************************************* + ** Review is read-only; Result describes the actual effect on selected rows. + *******************************************************************************/ + @ParameterizedTest + @MethodSource("actions") + void everyActionReviewsAndExecutes(String name) throws Exception + { + CrmActions.Action action = CrmActions.action(name); + seed(action); + Map inputs = inputs(); + Map before = snapshot(); + RunProcessOutput review = review(name, inputs, List.of(1, 2)); + assertThat(snapshot()).as("Review must not write any CRM table").isEqualTo(before); + List reviewLines = lines(review, false); + assertThat(reviewLines).isNotEmpty(); + assertThat(reviewLines).allSatisfy(line -> + { + assertThat(line.getSingularFutureMessage()).isNotBlank(); + assertThat(line.getPluralFutureMessage()).isNotBlank(); + assertThat(line.getSingularPastMessage()).isNotBlank(); + assertThat(line.getPluralPastMessage()).isNotBlank(); + assertThat(line.getPrimaryKeys()).isNotEmpty(); + }); + RunProcessOutput result = resume(name, review, Map.of()); + assertThat(result.getProcessState().getNextStepName()).hasValue("result"); + List resultLines = lines(result, true); + assertThat(resultLines).isNotEmpty(); + assertThat(resultLines).noneMatch(line -> line.getStatus() == Status.ERROR); + if(action.operation().equals("email")) + { + assertThat(resultLines).allMatch(line -> line.getStatus() == Status.WARNING); + assertThat(resultLines.getFirst().getSingularPastMessage()).isEqualTo(CrmAction.EMAIL_WARNING); + assertThat(snapshot()).isEqualTo(before); + } + else + { + assertThat(reviewLines).anyMatch(line -> line.getStatus() == Status.OK); + assertThat(resultLines).anyMatch(line -> line.getStatus() == Status.OK); + verifyEffect(action, resultLines); + } + if(!List.of("merge", "convert").contains(action.operation())) + { + assertThat(GetAction.execute(action.table(), 3).getValueString("ownerUserId")).isEqualTo("original"); + } + } + + /******************************************************************************* + ** Bindings, input screens, permissions, selection limits and removed processes. + *******************************************************************************/ + @Test + void metadataIsComplete() + { + assertThat(CrmActions.ACTIONS).hasSize(28); + assertThat(QContext.getQInstance().getProcesses().keySet().stream().filter(name -> !name.contains("."))).hasSize(30); + for(CrmActions.Action action : CrmActions.ACTIONS) + { + QProcessMetaData process = QContext.getQInstance().getProcess(action.name()); + assertThat(process.getTableName()).isEqualTo(action.table()); + assertThat(process.getMinInputRecords()).isPositive(); + assertThat(process.getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN); + assertThat(process.getFrontendStep("review")).isNotNull(); + assertThat(process.getFrontendStep("result")).isNotNull(); + if(!CrmActions.inputFields(action).isEmpty()) + { + assertThat(process.getFrontendStep("details")).isNotNull(); + assertThat(process.getStepList().indexOf(process.getFrontendStep("details"))) + .isLessThan(process.getStepList().indexOf(process.getFrontendStep("review"))); + } + if(action.operation().equals("merge")) + { + assertThat(process.getMinInputRecords()).isEqualTo(2); + assertThat(process.getMaxInputRecords()).isEqualTo(2); + } + } + for(String name : List.of("crm_process_sequence_steps", "crm_calculate_lead_scores")) + { + assertThat(QContext.getQInstance().getProcess(name).getTableName()).isNull(); + } + assertThat(QContext.getQInstance().getProcesses()).doesNotContainKeys("crm_fire_reminders", "crm_process_overdue_tasks", "crm_import_contacts", "crm_track_email_event"); + assertThat(QContext.getQInstance().getProcess("crm_move_deal_stage").getInputField("targetStageId").orElseThrow().getPossibleValueSourceFilter()).isNotNull(); + } + + /******************************************************************************* + ** Administrative and management actions have distinct permission grants. + *******************************************************************************/ + @Test + void permissionBoundaries() throws Exception + { + QueryInput input = new QueryInput("crm_contact"); + QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.WRITE)); + for(String name : List.of("crm_bulk_assign_owner", "crm_assign_company_owner", "crm_transfer_ownership", "crm_merge_contacts", "crm_merge_companies", + "crm_subscribe_to_list", "crm_enroll_in_sequence", "crm_activate_sequence", "crm_pause_sequence", "crm_gdpr_anonymize_contact")) + { + assertThat(PermissionsHelper.hasProcessPermission(input, name)).as(name).isFalse(); + } + assertThat(PermissionsHelper.hasProcessPermission(input, "crm_log_activity")).isTrue(); + } + + /******************************************************************************* + ** No writes when the selected target belongs to another pipeline. + *******************************************************************************/ + @Test + void crossPipelineIsWarningInReviewAndResult() throws Exception + { + seed(CrmActions.action("crm_move_deal_stage")); + Map values = inputs(); + values.put("targetStageId", 5); + RunProcessOutput review = review("crm_move_deal_stage", values, List.of(1, 2)); + assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING); + RunProcessOutput result = resume("crm_move_deal_stage", review, Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING); + assertThat(GetAction.execute("crm_deal", 1).getValueInteger("pipelineStageId")).isEqualTo(1); + } + + /******************************************************************************* + ** Missing input is not silently accepted through the process API. + *******************************************************************************/ + @Test + void missingOwnerIsWarning() throws Exception + { + seed(CrmActions.action("crm_bulk_assign_owner")); + RunProcessOutput review = review("crm_bulk_assign_owner", Map.of(), List.of(1, 2)); + assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING); + RunProcessOutput result = resume("crm_bulk_assign_owner", review, Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING); + assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("original"); + } + + /******************************************************************************* + ** Read-only users cannot mutate even if they can see the process. + *******************************************************************************/ + @Test + void readOnlySelectionIsSkipped() throws Exception + { + seed(CrmActions.action("crm_log_activity")); + QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.READ, CrmPermissions.MANAGE_READ)); + RunProcessOutput review = review("crm_log_activity", inputs(), List.of(1)); + assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING); + RunProcessOutput result = resume("crm_log_activity", review, Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING); + assertThat(rows("crm_activity")).isEmpty(); + } + + /******************************************************************************* + ** API callers cannot merge one/three records or select an unrelated survivor. + *******************************************************************************/ + @Test + void mergeRequiresExactlyTwoSelectedRecords() throws Exception + { + seed(CrmActions.action("crm_merge_contacts")); + Map before = snapshot(); + for(List ids : List.of(List.of(1), List.of(1, 2, 3))) + { + assertThatThrownBy(() -> review("crm_merge_contacts", inputs(), ids)).isInstanceOf(QException.class) + .hasMessageContaining("exactly two"); + } + Map values = inputs(); + values.put("primaryId", 3); + assertThatThrownBy(() -> review("crm_merge_contacts", values, List.of(1, 2))).isInstanceOf(QException.class) + .hasMessageContaining("one of the two selected"); + assertThat(snapshot()).isEqualTo(before); + } + + /******************************************************************************* + ** Missing selections cannot accidentally process an entire table. + *******************************************************************************/ + @Test + void selectionIsRequired() throws Exception + { + seed(CrmActions.action("crm_auto_assign")); + assertThatThrownBy(() -> new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_auto_assign"))) + .isInstanceOf(QException.class).hasMessageContaining("Select records"); + assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("original"); + } + + /******************************************************************************* + ** A failure on one row is reported as ERROR while other selected rows complete. + *******************************************************************************/ + @Test + void resultReportsRejectedUpdates() throws Exception + { + seed(CrmActions.action("crm_assign_company_owner")); + RunProcessOutput review = review("crm_assign_company_owner", inputs(), List.of(1, 2)); + assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.OK); + QContext.getQInstance().getTable("crm_company").withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(RejectFirstUpdate.class)); + RunProcessOutput result = resume("crm_assign_company_owner", review, Map.of()); + assertThat(lines(result, true)).anySatisfy(line -> + { + assertThat(line.getStatus()).isEqualTo(Status.ERROR); + assertThat(line.getPrimaryKeys()).containsExactly(1); + assertThat(line.getSingularPastMessage()).contains("rejected"); + }); + assertThat(lines(result, true)).anySatisfy(line -> + { + assertThat(line.getStatus()).isEqualTo(Status.OK); + assertThat(line.getPrimaryKeys()).containsExactly(2); + }); + assertThat(GetAction.execute("crm_company", 1).getValueString("ownerUserId")).isEqualTo("original"); + assertThat(GetAction.execute("crm_company", 2).getValueString("ownerUserId")).isEqualTo("new-owner"); + } + + /******************************************************************************* + ** Test-only backend rejection after the user has reviewed the selection. + *******************************************************************************/ + public static class RejectFirstUpdate extends AbstractPreUpdateCustomizer + { + @Override + public List apply(List records) + { + records.stream().filter(row -> Integer.valueOf(1).equals(row.getValueInteger("id"))) + .forEach(row -> row.addError(new BadInputStatusMessage("Update rejected for testing"))); + return (records); + } + } + + /******************************************************************************* + ** Revalidate consent-related suppression changed after the Review screen. + *******************************************************************************/ + @Test + void enrollmentRechecksSuppressionAfterReview() throws Exception + { + seed(CrmActions.action("crm_enroll_in_sequence")); + RunProcessOutput review = review("crm_enroll_in_sequence", inputs(), List.of(1, 2)); + new com.kingsrook.qqq.backend.core.actions.tables.UpdateAction().execute( + new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput("crm_contact") + .withRecord(new QRecord().withValue("id", 1).withValue("doNotEmail", true))); + RunProcessOutput result = resume("crm_enroll_in_sequence", review, Map.of()); + assertThat(lines(result, true)).anyMatch(line -> line.getStatus() == Status.WARNING && line.getPrimaryKeys().equals(List.of(1))); + assertThat(rows("crm_sequence_enrollment")).extracting(row -> row.getValueInteger("contactId")).containsExactly(2); + } + + /******************************************************************************* + ** Required stage fields are checked before any writes. + *******************************************************************************/ + @Test + void stageRequiredFieldIsReviewed() throws Exception + { + seed(CrmActions.action("crm_move_deal_stage")); + change("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("pipelineId", 1).withValue("name", "Needs company") + .withValue("requiredFieldsJson", "[\"companyId\"]")); + RunProcessOutput review = review("crm_move_deal_stage", inputs(), List.of(1, 2)); + assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.WARNING); + RunProcessOutput result = resume("crm_move_deal_stage", review, Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING); + assertThat(rows("crm_deal_stage_history")).isEmpty(); + } + + /******************************************************************************* + ** Invalid configuration produces standard ERROR lines on both screens. + *******************************************************************************/ + @Test + void validationErrorsAppearInReviewAndResult() throws Exception + { + seed(CrmActions.action("crm_move_deal_stage")); + change("crm_pipeline_stage", new QRecord().withValue("id", 2).withValue("requiredFieldsJson", "invalid json")); + RunProcessOutput review = review("crm_move_deal_stage", inputs(), List.of(1, 2)); + assertThat(lines(review, false)).allMatch(line -> line.getStatus() == Status.ERROR); + RunProcessOutput result = resume("crm_move_deal_stage", review, Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.ERROR); + assertThat(rows("crm_deal_stage_history")).isEmpty(); + } + + /******************************************************************************* + ** Request values cannot redirect an action to another table or record id. + *******************************************************************************/ + @Test + void installedBindingOwnsTheSelection() throws Exception + { + seed(CrmActions.action("crm_bulk_assign_owner")); + Map values = inputs(); + values.put("sourceTable", "crm_company"); + values.put("destinationTable", "crm_company"); + values.put("recordId", 3); + values.put("contactId", 3); + RunProcessOutput result = resume("crm_bulk_assign_owner", review("crm_bulk_assign_owner", values, List.of(1, 2)), Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.OK); + assertThat(rows("crm_company")).allMatch(row -> "original".equals(row.getValueString("ownerUserId"))); + assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("new-owner"); + assertThat(GetAction.execute("crm_contact", 3).getValueString("ownerUserId")).isEqualTo("original"); + } + + /******************************************************************************* + ** No rules produces an honest warning instead of a successful assignment. + *******************************************************************************/ + @Test + void autoAssignNoMatchIsWarning() throws Exception + { + seed(CrmActions.action("crm_auto_assign")); + change("crm_assignment_rule", new QRecord().withValue("id", 1).withValue("isActive", false)); + RunProcessOutput result = resume("crm_auto_assign", review("crm_auto_assign", inputs(), List.of(1, 2)), Map.of()); + assertThat(lines(result, true)).allMatch(line -> line.getStatus() == Status.WARNING); + assertThat(GetAction.execute("crm_contact", 1).getValueString("ownerUserId")).isEqualTo("original"); + } + + /******************************************************************************* + ** Scoring crosses two full keyset pages, including non-contiguous ids. + *******************************************************************************/ + @Test + void scoringPagesInBatchesOf200() throws Exception + { + seed(CrmActions.action("crm_recalculate_score")); + for(int i = 4; i <= 405; i++) + { + put("crm_contact", new QRecord().withValue("id", 10 + i * 2).withValue("lastName", "Contact").withValue("ownerUserId", "original")); + } + PageObserver.sizes.clear(); + QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class)); + RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_calculate_lead_scores")); + assertThat(result.getValues().get("totalContacts")).isEqualTo(405); + assertThat(result.getValues().get("updatedCount")).isEqualTo(405); + assertThat(PageObserver.sizes).containsExactly(200, 200, 5); + QContext.getQInstance().getTable("crm_contact").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole()); + assertThat(rows("crm_contact")).hasSize(405).allMatch(row -> row.getValueInteger("leadScore") == 10); + } + + /******************************************************************************* + ** Changing enrollment status cannot shift an offset and skip the next page. + *******************************************************************************/ + @Test + void sequencePagesInBatchesOf200() throws Exception + { + seed(CrmActions.action("crm_enroll_in_sequence")); + for(int i = 1; i <= 405; i++) + { + put("crm_sequence_enrollment", new QRecord().withValue("id", i * 2).withValue("sequenceId", 1).withValue("contactId", 1) + .withValue("status", 1).withValue("currentStepNumber", 0).withValue("failureCount", 0).withValue("nextStepDate", Instant.now().minusSeconds(60))); + } + put("crm_sequence_enrollment", new QRecord().withValue("id", 9999).withValue("sequenceId", 1).withValue("contactId", 1) + .withValue("status", 1).withValue("currentStepNumber", 0).withValue("nextStepDate", Instant.now().plusSeconds(86400))); + PageObserver.sizes.clear(); + QContext.getQInstance().getTable("crm_sequence_enrollment").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class)); + RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_process_sequence_steps")); + assertThat(result.getValues().get("processedCount")).isEqualTo(405); + assertThat(PageObserver.sizes).containsExactly(200, 200, 5); + QContext.getQInstance().getTable("crm_sequence_enrollment").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole()); + assertThat(rows("crm_sequence_enrollment").stream().filter(row -> row.getValueInteger("id") != 9999)) + .hasSize(405).allMatch(row -> row.getValueInteger("status") == 3); + assertThat(GetAction.execute("crm_sequence_enrollment", 9999).getValueInteger("status")).isEqualTo(1); + } + + /******************************************************************************* + ** Observe the actual query bounds, not just the final job counts. + *******************************************************************************/ + public static class PageObserver extends AbstractPostQueryCustomizer + { + private static final List sizes = new ArrayList<>(); + + @Override + public List apply(List records) + { + if(input instanceof QueryInput query && query.getFilter().getOrderBys() != null && !query.getFilter().getOrderBys().isEmpty()) + { + assertThat(query.getFilter().getLimit()).isEqualTo(200); + assertThat(query.getFilter().getOrderBys().getFirst().getFieldName()).isEqualTo("id"); + sizes.add(records.size()); + } + return (records); + } + } + + /******************************************************************************* + ** Verify observable writes, including merge relationships and duplicate links. + *******************************************************************************/ + private void verifyEffect(CrmActions.Action action, List summary) throws QException + { + QRecord first = GetAction.execute(action.table(), 1); + switch(action.operation()) + { + case "assign" -> assertThat(first.getValueString("ownerUserId")).isEqualTo("new-owner"); + case "auto" -> assertThat(first.getValueString("ownerUserId")).isEqualTo("auto-owner"); + case "subscribe" -> assertThat(rows("crm_email_list_member")).hasSize(2); + case "unsubscribe" -> assertThat(rows("crm_email_list_member")).allMatch(row -> row.getValueInstant("unsubscribedDate") != null); + case "enroll" -> assertThat(rows("crm_sequence_enrollment")).hasSize(2); + case "unenroll" -> assertThat(rows("crm_sequence_enrollment")).allMatch(row -> row.getValueInstant("unenrolledDate") != null); + case "activity" -> + { + assertThat(rows("crm_activity")).hasSize(2); + String foreignKey = action.table().substring(4) + "Id"; + assertThat(rows("crm_activity")).extracting(row -> row.getValueInteger(foreignKey)).containsExactlyInAnyOrder(1, 2); + } + case "score" -> assertThat(first.getValueInteger("leadScore")).isEqualTo(10); + case "merge" -> + { + assertThat(GetAction.execute(action.table(), 2)).isNull(); + assertThat(GetAction.execute(action.table(), 3)).isNotNull(); + } + case "duplicates" -> + { + assertThat(summary).anySatisfy(line -> + { + assertThat(line.getStatus()).isEqualTo(Status.WARNING); + assertThat(line.getPrimaryKeys()).containsExactlyInAnyOrder(1, 2); + }); + assertThat(rows(action.table())).hasSize(3); + } + case "createDeal", "clone" -> assertThat(rows("crm_deal")).hasSize(5); + case "erase" -> + { + assertThat(first.getValueBoolean("doNotEmail")).isTrue(); + assertThat(first.getValueString("email")).endsWith("@removed.invalid"); + } + case "stage" -> assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(2); + case "won" -> + { + assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(3); + assertThat(first.getValueInteger("winLossReasonId")).isEqualTo(1); + assertThat(first.getValueLocalDate("actualCloseDate")).isNotNull(); + assertThat(rows("crm_deal_stage_history")).hasSize(2); + } + case "lost" -> assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(4); + case "reopen" -> + { + assertThat(first.getValueInteger("pipelineStageId")).isEqualTo(2); + assertThat(first.getValue("actualCloseDate")).isNull(); + assertThat(first.getValue("winLossReasonId")).isNull(); + } + case "addContact" -> assertThat(rows("crm_deal_contact")).hasSize(2); + case "convert" -> + { + assertThat(first.getValueInteger("contactId")).isNotNull(); + assertThat(GetAction.execute("crm_form_submission", 2).getValueInteger("contactId")).isNotNull(); + assertThat(rows("crm_contact")).hasSize(5); + } + case "activate" -> assertThat(first.getValueBoolean("isActive")).isTrue(); + case "pause" -> assertThat(first.getValueBoolean("isActive")).isFalse(); + default -> throw (new AssertionError(action.name())); + } + } + + /******************************************************************************* + ** Start, complete input details, and stop at the actual standard Review screen. + *******************************************************************************/ + private RunProcessOutput review(String name, Map values, List ids) throws QException + { + RunProcessInput request = new RunProcessInput(); + request.setProcessName(name); + request.setValues(new HashMap<>(values)); + request.addValue("queryFilterJson", JsonUtils.toJson(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)))); + RunProcessOutput output = new RunProcessAction().execute(request); + assertThat(output.getException()).isEmpty(); + if(output.getProcessState().getNextStepName().orElse("").equals("details")) + { + output = resume(name, output, values); + } + assertThat(output.getProcessState().getNextStepName()).hasValue("review"); + return (output); + } + + /******************************************************************************* + ** Resume the process just as the frontend submits its current screen. + *******************************************************************************/ + private RunProcessOutput resume(String name, RunProcessOutput prior, Map values) throws QException + { + RunProcessInput request = new RunProcessInput(); + request.setProcessName(name); + request.setProcessUUID(prior.getProcessUUID()); + request.setStartAfterStep(prior.getProcessState().getNextStepName().orElseThrow()); + request.setValues(new HashMap<>(values)); + RunProcessOutput output = new RunProcessAction().execute(request); + assertThat(output.getException()).isEmpty(); + return (output); + } + + /******************************************************************************* + ** Read standard ETL summaries, not test-specific step state. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private List lines(RunProcessOutput output, boolean result) + { + return ((List) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY)); + } + + /******************************************************************************* + ** Supply all action inputs; each operation accepts only its declared fields. + *******************************************************************************/ + private Map inputs() + { + Map values = new HashMap<>(); + values.put("newOwnerUserId", "new-owner"); + values.put("emailListId", 1); + values.put("sequenceId", 1); + values.put("primaryId", 1); + values.put("pipelineId", 1); + values.put("targetStageId", 2); + values.put("winLossReasonId", 1); + values.put("dealName", "New deal"); + values.put("dealAmount", new BigDecimal("250")); + values.put("contactId", 1); + values.put("activityTypeId", 1); + values.put("subject", "Phone conversation"); + values.put("description", "Discussed next steps"); + return (values); + } + + /******************************************************************************* + ** Stable persisted fixtures, including a third record excluded from selections. + *******************************************************************************/ + private void seed(CrmActions.Action action) throws QException + { + put("crm_pipeline", new QRecord().withValue("id", 1).withValue("name", "Sales").withValue("isDefault", true)); + put("crm_pipeline", new QRecord().withValue("id", 2).withValue("name", "Other")); + for(int id = 1; id <= 5; id++) + { + put("crm_pipeline_stage", new QRecord().withValue("id", id).withValue("name", "Stage " + id).withValue("pipelineId", id == 5 ? 2 : 1) + .withValue("sortOrder", id).withValue("probabilityPct", id == 3 ? 100 : 50).withValue("isClosedWon", id == 3).withValue("isClosedLost", id == 4)); + } + put("crm_win_loss_reason", new QRecord().withValue("id", 1).withValue("name", "Budget")); + put("crm_activity_type", new QRecord().withValue("id", 1).withValue("name", "Note")); + put("crm_email_list", new QRecord().withValue("id", 1).withValue("name", "Newsletter").withValue("isActive", true)); + put("crm_assignment_rule", new QRecord().withValue("id", 1).withValue("name", "Assign").withValue("entityType", 1) + .withValue("assignStrategy", 1).withValue("assignToUserId", "auto-owner").withValue("isActive", true).withValue("sortOrder", 1)); + put("crm_lead_score_rule", new QRecord().withValue("id", 1).withValue("name", "Name present").withValue("fieldPath", "lastName") + .withValue("operator", 6).withValue("scoreAdjustment", 10).withValue("isActive", true).withValue("sortOrder", 1)); + for(int id = 1; id <= 3; id++) + { + String email = action.operation().equals("duplicates") && id < 3 ? (id == 1 ? "A@EXAMPLE.COM" : "a@example.com") : "person" + id + "@example.com"; + put("crm_contact", new QRecord().withValue("id", id).withValue("firstName", "Person").withValue("lastName", "Contact " + id) + .withValue("email", email).withValue("ownerUserId", "original").withValue("leadScore", 0)); + put("crm_company", new QRecord().withValue("id", id).withValue("name", "Company " + id).withValue("ownerUserId", "original") + .withValue("domain", id < 3 ? (id == 1 ? "EXAMPLE.COM" : "example.com") : "different.com")); + put("crm_deal", new QRecord().withValue("id", id).withValue("name", "Deal " + id).withValue("pipelineId", 1) + .withValue("pipelineStageId", action.operation().equals("reopen") ? 3 : 1).withValue("amount", new BigDecimal("100")) + .withValue("currencyCode", "USD").withValue("ownerUserId", "original").withValue("stageEnteredDate", Instant.now().minusSeconds(86400))); + put("crm_form_submission", new QRecord().withValue("id", id).withValue("email", "form" + id + "@example.com") + .withValue("firstName", "Web").withValue("lastName", "Lead " + id).withValue("ownerUserId", "original")); + put("crm_email_sequence", new QRecord().withValue("id", id).withValue("name", "Sequence " + id) + .withValue("isActive", !action.operation().equals("activate")).withValue("ownerUserId", "original")); + if(id <= 2 && action.operation().equals("unsubscribe")) + { + put("crm_email_list_member", new QRecord().withValue("id", id).withValue("emailListId", 1).withValue("contactId", id) + .withValue("email", email).withValue("status", 1)); + } + if(id <= 2 && action.operation().equals("unenroll")) + { + put("crm_sequence_enrollment", new QRecord().withValue("id", id).withValue("sequenceId", 1).withValue("contactId", id) + .withValue("status", 1).withValue("currentStepNumber", 0).withValue("failureCount", 0)); + } + } + } + + /******************************************************************************* + ** Raw fixtures intentionally bypass insert automation and uniqueness for duplicates. + *******************************************************************************/ + private void put(String table, QRecord row) throws QException + { + MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(row), true); + } + + /******************************************************************************* + ** Update an existing fixture through normal DML. + *******************************************************************************/ + private void change(String table, QRecord row) throws QException + { + new com.kingsrook.qqq.backend.core.actions.tables.UpdateAction().execute( + new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(table).withRecord(row)); + } + + /******************************************************************************* + ** Query through the normal session. + *******************************************************************************/ + private List rows(String table) throws QException + { + return (new QueryAction().execute(new QueryInput(table)).getRecords()); + } + + /******************************************************************************* + ** Capture persisted values in every table to detect accidental Review writes. + *******************************************************************************/ + private Map snapshot() throws QException + { + Map snapshot = new TreeMap<>(); + for(String table : QContext.getQInstance().getTables().keySet()) + { + List> values = new ArrayList<>(); + rows(table).forEach(row -> values.add(new TreeMap<>(row.getValues()))); + snapshot.put(table, JsonUtils.toJson(values)); + } + return (snapshot); + } +} -- 2.54.0 From a93365a7b7c93dd85310b88e5868d3f416fdc96d Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:00:39 -0500 Subject: [PATCH 2/2] refactor: retire reminder, overdue, import, track and old bulk processes; page background jobs Co-Authored-By: Claude Opus 5.5 --- README.md | 90 ++++--- .../processes/FireRemindersProcess.java | 121 --------- .../processes/LogActivityProcess.java | 54 +--- .../processes/ProcessOverdueTasksProcess.java | 156 ------------ .../processes/BulkAssignOwnerProcess.java | 124 --------- .../ConvertFormToContactProcess.java | 37 +-- .../processes/DetectDuplicatesProcess.java | 35 +-- .../GdprAnonymizeContactProcess.java | 37 +-- .../core/processes/ImportContactsProcess.java | 159 ------------ .../core/processes/MergeCompaniesProcess.java | 37 +-- .../core/processes/MergeContactsProcess.java | 37 +-- .../processes/SubscribeToListProcess.java | 39 +-- .../processes/UnsubscribeFromListProcess.java | 37 +-- .../crm/deals/processes/CloneDealProcess.java | 37 +-- .../deals/processes/ConvertLeadProcess.java | 40 +-- .../processes/ManageContactOnDealProcess.java | 40 +-- .../deals/processes/MoveDealStageProcess.java | 37 +-- .../deals/processes/ReopenDealProcess.java | 38 +-- .../processes/TransferOwnershipProcess.java | 37 +-- .../processes/EnrollInSequenceProcess.java | 38 +-- .../ProcessSequenceStepsProcess.java | 46 ++-- .../crm/email/processes/SendEmailProcess.java | 236 ------------------ .../processes/TrackEmailEventProcess.java | 222 ---------------- .../UnenrollFromSequenceProcess.java | 36 +-- .../scoring/processes/AutoAssignProcess.java | 36 +-- .../processes/CalculateLeadScoresProcess.java | 68 ++--- .../qbits/crm/security/CrmPermissions.java | 3 +- .../com/kingsrook/qbits/crm/BaseTest.java | 33 +++ .../crm/ContingentPossibleValuesTest.java | 7 +- .../processes/FireRemindersProcessTest.java | 139 ----------- .../processes/LogActivityProcessTest.java | 13 +- .../ProcessOverdueTasksProcessTest.java | 144 ----------- .../processes/BulkAssignOwnerProcessTest.java | 97 ------- .../ConvertFormToContactProcessTest.java | 13 +- .../DetectDuplicatesProcessTest.java | 17 +- .../GdprAnonymizeContactProcessTest.java | 9 +- .../processes/ImportContactsProcessTest.java | 154 ------------ .../processes/MergeCompaniesProcessTest.java | 7 +- .../processes/MergeContactsProcessTest.java | 7 +- .../processes/SubscribeToListProcessTest.java | 21 +- .../deals/processes/CloneDealProcessTest.java | 9 +- .../processes/ConvertLeadProcessTest.java | 17 +- .../ManageContactOnDealProcessTest.java | 15 +- .../processes/MoveDealStageProcessTest.java | 27 +- .../processes/ReopenDealProcessTest.java | 15 +- .../TransferOwnershipProcessTest.java | 7 +- .../EnrollInSequenceProcessTest.java | 25 +- .../email/processes/SendEmailProcessTest.java | 220 ---------------- .../processes/TrackEmailEventProcessTest.java | 196 --------------- .../AutoAssignProcessCoverageTest.java | 27 +- .../processes/AutoAssignProcessTest.java | 19 +- 51 files changed, 311 insertions(+), 2804 deletions(-) delete mode 100644 src/main/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcess.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcess.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcessTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcessTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcessTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcessTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcessTest.java diff --git a/README.md b/README.md index 25f87fa..6e7387e 100644 --- a/README.md +++ b/README.md @@ -90,35 +90,56 @@ Contact, company, deal and activity declare core FIELD-level audit rules. The be ## Processes -The QBit produces 25 processes with these names and labels. QQQ may additionally generate standard table actions such as `.bulkInsert`; those retain core's naming conventions. CRM processes are available to host wiring and are not registered as scheduled jobs. +The QBit produces 28 table-bound record actions. Every action uses the standard +streamed ETL Review and Result screens with linked summary lines. Required inputs +are collected before Review. Processes never appear in app navigation. Find +Duplicates scans the selected records (select all matching records to scan the +table); each duplicate group links to its members. Merges require exactly two +selected records and a choice of which record to keep. -| Process name | Label | -|---|---| -| `crm_auto_assign` | Auto Assign | -| `crm_bulk_assign_owner` | Bulk Assign Owner | -| `crm_calculate_lead_scores` | Calculate Lead Scores | -| `crm_clone_deal` | Clone Deal | -| `crm_convert_form_to_contact` | Convert Form to Contact | -| `crm_convert_lead` | Convert Lead | -| `crm_detect_duplicates` | Detect Duplicates | -| `crm_enroll_in_sequence` | Enroll in Sequence | -| `crm_fire_reminders` | Fire Reminders | -| `crm_gdpr_anonymize_contact` | GDPR Anonymize Contact | -| `crm_import_contacts` | Import Contacts | -| `crm_log_activity` | Log Activity | -| `crm_manage_contact_on_deal` | Manage Contact on Deal | -| `crm_merge_companies` | Merge Companies | -| `crm_merge_contacts` | Merge Contacts | -| `crm_move_deal_stage` | Move Deal Stage | -| `crm_process_overdue_tasks` | Process Overdue Tasks | -| `crm_process_sequence_steps` | Process Sequence Steps | -| `crm_reopen_deal` | Reopen Deal | -| `crm_send_email` | Send Email | -| `crm_subscribe_to_list` | Subscribe to List | -| `crm_track_email_event` | Track Email Event | -| `crm_transfer_ownership` | Transfer Ownership | -| `crm_unenroll_from_sequence` | Unenroll from Sequence | -| `crm_unsubscribe_from_list` | Unsubscribe from List | +| Table | Process name | Label | +|---|---|---| +| `crm_contact` | `crm_bulk_assign_owner` | Assign Owner | +| `crm_contact` | `crm_auto_assign` | Auto-Assign | +| `crm_contact` | `crm_subscribe_to_list` | Add to List | +| `crm_contact` | `crm_unsubscribe_from_list` | Remove from List | +| `crm_contact` | `crm_enroll_in_sequence` | Enroll in Sequence | +| `crm_contact` | `crm_unenroll_from_sequence` | Unenroll from Sequence | +| `crm_contact` | `crm_log_activity` | Log Activity | +| `crm_contact` | `crm_recalculate_score` | Recalculate Score | +| `crm_contact` | `crm_merge_contacts` | Merge Contacts | +| `crm_contact` | `crm_detect_duplicates` | Find Duplicates | +| `crm_contact` | `crm_convert_lead` | Create Deal | +| `crm_contact` | `crm_gdpr_anonymize_contact` | Erase Personal Data | +| `crm_contact` | `crm_send_email` | Send Email | +| `crm_company` | `crm_assign_company_owner` | Assign Owner | +| `crm_company` | `crm_merge_companies` | Merge Companies | +| `crm_company` | `crm_find_duplicate_companies` | Find Duplicates | +| `crm_company` | `crm_log_company_activity` | Log Activity | +| `crm_deal` | `crm_move_deal_stage` | Move Stage | +| `crm_deal` | `crm_mark_deal_won` | Mark Won | +| `crm_deal` | `crm_mark_deal_lost` | Mark Lost | +| `crm_deal` | `crm_reopen_deal` | Reopen | +| `crm_deal` | `crm_clone_deal` | Clone | +| `crm_deal` | `crm_transfer_ownership` | Assign Owner | +| `crm_deal` | `crm_manage_contact_on_deal` | Add Contact | +| `crm_deal` | `crm_log_deal_activity` | Log Activity | +| `crm_form_submission` | `crm_convert_form_to_contact` | Convert to Contact | +| `crm_email_sequence` | `crm_activate_sequence` | Activate | +| `crm_email_sequence` | `crm_pause_sequence` | Pause | + +Move Stage and Reopen choose a stage from the selected deals' pipeline. Select +one pipeline at a time for those actions. Mark Won and Mark Lost require a +win/loss reason and resolve the terminal stage within each deal's pipeline. + +Two hidden background processes have no table binding: `crm_process_sequence_steps` +and `crm_calculate_lead_scores`. Both use id keyset pages of 200. They have no +shipped schedules yet; the host can invoke them. QQQ also generates standard +bulk-load and bulk-edit actions such as `
.bulkInsert`. + +Fire Reminders and Process Overdue Tasks were removed in favor of collaboration +tasks and alerts. Import Contacts was removed in favor of core bulk load. Track +Email Event was removed in favor of the later delivery sync. ## Current behavior and limits @@ -126,17 +147,18 @@ The existing pipeline/stage logic, stage history, product totals, currency conve The contact Timeline shows the latest fifty recorded events from activities, email messages, form submissions and consent, newest first by creation date. Native child lists connect all specified parent screens. Deal primary-contact and email-message contact references are included in the fresh-install schema. The trend widgets, redundant recent-deals/activity-count widgets and activity feed have been retired. -Some process names describe unfinished behavior inherited from 0.1.0: +Later phases complete the following behavior: -- Send Email and sequence email steps record CRM activity/message state; they do not deliver email. Template merging and live delivery tracking are not implemented here. -- Sequence execution, lead scoring and overdue-task work require host invocation. No schedules are shipped. Assignment is a process, not an automatic insert hook; reminders do not deliver notifications. -- Scoring's date-decay operators remain unimplemented. Import Contacts expects pre-parsed input and does not provide the planned streamed bulk-load workflow. +- Send Email is a non-sending action shell. It checks for `CrmEmailTransport` with `Class.forName` and reports **Email sending arrives with the email transport** when absent. It never writes a fake sent message or activity. If the class is present, the shell reports that action integration is still pending. Sequence email steps retain the existing activity-only behavior until transport integration. +- Sequence execution and lead scoring require host invocation. No schedules are shipped. Assignment remains an action; automatic insert hooks, collaboration and alert integration arrive later. +- Activate/Pause update the sequence active flag. The existing sequence runner's business logic is preserved in P2B and does not yet consult that flag; enforcing it during execution is a follow-up. +- Scoring's date-decay operators remain unimplemented. Core bulk load is the import path; saved CRM import profiles arrive later. - GDPR anonymization scrubs the contact, unenrolls sequences and records withdrawn consent. It does not yet erase all linked PII or core audit details. The P1 schema retains existing PII fields; classification, complete erasure and redesigned tenancy are later work. -The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 actions/security/integration work are not part of this phase. +The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase. ## Source layout -`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`. +`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`. Licensed under [Apache License 2.0](LICENSE). diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcess.java b/src/main/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcess.java deleted file mode 100644 index 379f0ed..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcess.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Scheduled process that fires reminders for activities with isReminderSet - ** = true and reminderDate <= now. Clears the reminder flag after processing. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.activities.processes; - - -import java.time.Instant; -import java.util.List; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -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.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; - - -/******************************************************************************* - ** MetaDataProducer + BackendStep for firing reminders. Intended to run on a - ** 5-minute schedule. Finds activities with active reminders whose - ** reminderDate has passed, clears the flag, and would fire notifications - ** in a production environment. - *******************************************************************************/ -public class FireRemindersProcess implements BackendStep, MetaDataProducerInterface -{ - public static final String NAME = "crm_fire_reminders"; - - - - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Fire Reminders") - .withIcon(new QIcon().withName("alarm")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(FireRemindersProcess.class)) - )); - } - - - - /******************************************************************************* - ** Execute: find activities with due reminders and clear the reminder flag. - *******************************************************************************/ - @Override - public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException - { - /////////////////////////////////////////////// - // query activities with pending reminders // - /////////////////////////////////////////////// - QueryOutput queryOutput = new QueryAction().execute( - new QueryInput(Activity.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("isReminderSet", QCriteriaOperator.EQUALS, true)) - .withCriteria(new QFilterCriteria("reminderDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now())) - .withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false)))); - - int firedCount = 0; - for(QRecord record : queryOutput.getRecords()) - { - ///////////////////////////////////////////////////////////// - // In production this would fire a notification. For now, // - // just clear the reminder flag. // - ///////////////////////////////////////////////////////////// - QRecord updateRecord = new QRecord() - .withValue("id", record.getValueInteger("id")) - .withValue("isReminderSet", false); - - new UpdateAction().execute( - new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord)); - - firedCount++; - } - - output.addValue("firedCount", firedCount); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcess.java b/src/main/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcess.java index 8183992..d6e6f0e 100644 --- a/src/main/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcess.java @@ -27,13 +27,7 @@ package com.kingsrook.qbits.crm.activities.processes; -import java.util.List; import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityOutcome; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qbits.crm.core.model.Company; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.context.QContext; @@ -42,62 +36,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu 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.actions.tables.insert.InsertOutput; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep that creates a crm_activity record from + ** Backend operation used by the streamed CRM actions that creates a crm_activity record from ** the provided input fields. Sets ownerUserId from the current session. *******************************************************************************/ -public class LogActivityProcess implements BackendStep, MetaDataProducerInterface +public class LogActivityProcess implements BackendStep { public static final String NAME = "crm_log_activity"; - /******************************************************************************* - ** Produce the process metadata with input fields for all activity fields. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Log Activity") - .withTableName(Activity.TABLE_NAME) - .withIcon(new QIcon().withName("event_note")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(LogActivityProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("activityTypeId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(ActivityType.TABLE_NAME), - new QFieldMetaData("subject", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("description", QFieldType.STRING), - new QFieldMetaData("contactId", QFieldType.INTEGER).withPossibleValueSourceName(Contact.TABLE_NAME), - new QFieldMetaData("companyId", QFieldType.INTEGER).withPossibleValueSourceName(Company.TABLE_NAME), - new QFieldMetaData("dealId", QFieldType.INTEGER), - new QFieldMetaData("direction", QFieldType.INTEGER).withPossibleValueSourceName(CrmDirection.NAME), - new QFieldMetaData("durationMinutes", QFieldType.INTEGER), - new QFieldMetaData("outcomeId", QFieldType.INTEGER).withPossibleValueSourceName(ActivityOutcome.TABLE_NAME), - new QFieldMetaData("location", QFieldType.STRING), - new QFieldMetaData("conferenceLink", QFieldType.STRING), - new QFieldMetaData("startDate", QFieldType.DATE_TIME), - new QFieldMetaData("endDate", QFieldType.DATE_TIME) - ))) - )); - } - - - /******************************************************************************* ** Execute the process: build an Activity entity from inputs and insert it. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcess.java b/src/main/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcess.java deleted file mode 100644 index 6dfc072..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcess.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Scheduled process that finds overdue tasks and marks them with a system - ** note. In production this would fire notifications; for now it logs a - ** message on each overdue activity. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.activities.processes; - - -import java.time.Instant; -import java.util.List; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -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.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; - - -/******************************************************************************* - ** MetaDataProducer + BackendStep for processing overdue tasks. Intended to - ** run on an hourly schedule. Finds activities of type "Task" with dueDate - ** in the past and isCompleted=false. - *******************************************************************************/ -public class ProcessOverdueTasksProcess implements BackendStep, MetaDataProducerInterface -{ - public static final String NAME = "crm_process_overdue_tasks"; - - - - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Process Overdue Tasks") - .withIcon(new QIcon().withName("warning")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(ProcessOverdueTasksProcess.class)) - )); - } - - - - /******************************************************************************* - ** Execute: find overdue tasks and mark them with a system note. - *******************************************************************************/ - @Override - public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException - { - /////////////////////////////////////////////// - // look up the "Task" activity type // - /////////////////////////////////////////////// - Integer taskTypeId = lookupTaskActivityTypeId(); - if(taskTypeId == null) - { - output.addValue("processedCount", 0); - return; - } - - /////////////////////////////////////////////// - // query overdue, incomplete tasks // - /////////////////////////////////////////////// - QueryOutput queryOutput = new QueryAction().execute( - new QueryInput(Activity.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("activityTypeId", QCriteriaOperator.EQUALS, taskTypeId)) - .withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now())) - .withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false)))); - - int processedCount = 0; - for(QRecord record : queryOutput.getRecords()) - { - ///////////////////////////////////////////////////////////////////// - // In production this would create a notification. For now, add a // - // system note to the description indicating it is overdue. // - ///////////////////////////////////////////////////////////////////// - String currentDescription = record.getValueString("description"); - String note = "[OVERDUE] Task past due date."; - String newDescription = currentDescription != null ? currentDescription + " " + note : note; - - QRecord updateRecord = new QRecord() - .withValue("id", record.getValueInteger("id")) - .withValue("description", newDescription); - - new UpdateAction().execute( - new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord)); - - processedCount++; - } - - output.addValue("processedCount", processedCount); - } - - - - /*************************************************************************** - ** Look up the "Task" activity type id. Returns null if not found. - ***************************************************************************/ - private Integer lookupTaskActivityTypeId() throws QException - { - QueryOutput queryOutput = new QueryAction().execute( - new QueryInput(ActivityType.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Task")))); - - if(!queryOutput.getRecords().isEmpty()) - { - return (queryOutput.getRecords().get(0).getValueInteger("id")); - } - - return (null); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java deleted file mode 100644 index 6990c36..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Bulk action process to assign a new owner to selected records. Works on - ** any table that has an ownerUserId field. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.core.processes; - - -import java.util.ArrayList; -import java.util.List; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; -import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; -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.processes.Status; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; -import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; -import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; - - -/******************************************************************************* - ** MetaDataProducer for the BulkAssignOwner table-level process. Uses the - ** StreamedETL pattern with a transform step that sets ownerUserId on each - ** record to the newOwnerUserId input value. - *******************************************************************************/ -public class BulkAssignOwnerProcess implements MetaDataProducerInterface -{ - public static final String NAME = "crm_bulk_assign_owner"; - - - - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder() - .withName(NAME) - .withLabel("Bulk Assign Owner") - .withTableName(Contact.TABLE_NAME) - .withIcon(new QIcon().withName("person_pin")) - .withTransformStepClass(BulkAssignOwnerTransformStep.class) - .withPreviewMessage("Records to be assigned a new owner") - .withReviewStepRecordFields(List.of( - new QFieldMetaData("id", QFieldType.INTEGER), - new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Current Owner") - )) - .getProcessMetaData(); - - return (process); - } - - - - /******************************************************************************* - ** Transform step that sets ownerUserId on each record. - *******************************************************************************/ - public static class BulkAssignOwnerTransformStep extends AbstractTransformStep - { - private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK) - .withSingularFutureMessage("will be assigned a new owner") - .withPluralFutureMessage("will be assigned a new owner") - .withSingularPastMessage("was assigned a new owner") - .withPluralPastMessage("were assigned a new owner"); - - /******************************************************************************* - ** Set ownerUserId on each record from the newOwnerUserId input. - *******************************************************************************/ - @Override - public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException - { - String newOwnerUserId = input.getValueString("newOwnerUserId"); - - for(QRecord record : input.getRecords()) - { - record.setValue("ownerUserId", newOwnerUserId); - output.addRecord(record); - okLine.incrementCountAndAddPrimaryKey(record.getValueInteger("id")); - } - } - - /******************************************************************************* - ** Provide process summary. - *******************************************************************************/ - @Override - public ArrayList getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen) - { - ArrayList rs = new ArrayList<>(); - okLine.addSelfToListIfAnyCount(rs); - return (rs); - } - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcess.java index 0ac000a..7c1e1e3 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcess.java @@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.processes; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.activities.model.ActivityType; import com.kingsrook.qbits.crm.core.model.Contact; @@ -54,52 +53,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; /******************************************************************************* - ** MetaDataProducer + BackendStep for converting a form submission into a + ** Backend operation used by the streamed CRM actions for converting a form submission into a ** contact record. Deduplicates on email, updates the form with conversion ** data, and creates a Note activity on the contact. *******************************************************************************/ -public class ConvertFormToContactProcess implements BackendStep, MetaDataProducerInterface +public class ConvertFormToContactProcess implements BackendStep { public static final String NAME = "crm_convert_form_to_contact"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Convert Form to Contact") - .withTableName(FormSubmission.TABLE_NAME) - .withIcon(new QIcon().withName("swap_horiz")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(ConvertFormToContactProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("formSubmissionId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the conversion process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcess.java index 6d1d89e..4b0b7c8 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcess.java @@ -41,49 +41,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; /******************************************************************************* - ** MetaDataProducer + BackendStep for detecting duplicate contacts or companies. + ** Backend operation used by the streamed CRM actions for detecting duplicate contacts or companies. *******************************************************************************/ -public class DetectDuplicatesProcess implements BackendStep, MetaDataProducerInterface +public class DetectDuplicatesProcess implements BackendStep { public static final String NAME = "crm_detect_duplicates"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Detect Duplicates") - .withIcon(new QIcon().withName("find_replace")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(DetectDuplicatesProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the detect duplicates process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java index b738a6e..aab5a64 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcess.java @@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.processes; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.core.model.ConsentRecord; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; @@ -52,21 +51,12 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for GDPR anonymization of a contact. + ** Backend operation used by the streamed CRM actions for GDPR anonymization of a contact. *******************************************************************************/ -public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProducerInterface +public class GdprAnonymizeContactProcess implements BackendStep { private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class); @@ -76,29 +66,6 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("GDPR Anonymize Contact") - .withTableName(Contact.TABLE_NAME) - .withIcon(new QIcon().withName("privacy_tip")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(GdprAnonymizeContactProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the GDPR anonymization process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java deleted file mode 100644 index 7ca32c0..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Simplified import process for contacts. Takes a list of QRecords, - ** validates required fields, checks for duplicates by email, inserts new - ** contacts, with core table auditing. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.core.processes; - - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.context.QContext; -import com.kingsrook.qqq.backend.core.exceptions.QException; -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.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; -import com.kingsrook.qqq.backend.core.utils.StringUtils; - - -/******************************************************************************* - ** MetaDataProducer + BackendStep for importing contacts from QRecords. - *******************************************************************************/ -public class ImportContactsProcess implements BackendStep, MetaDataProducerInterface -{ - public static final String NAME = "crm_import_contacts"; - - - - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Import Contacts") - .withTableName(Contact.TABLE_NAME) - .withIcon(new QIcon().withName("upload")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(ImportContactsProcess.class)) - )); - } - - - - /******************************************************************************* - ** Execute the import contacts process. - *******************************************************************************/ - @Override - public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException - { - List inputRecords = input.getRecords(); - String sessionUserId = QContext.getQSession().getIdReference(); - - //////////////////////////////////////////////////////////// - // build set of existing emails for duplicate detection // - //////////////////////////////////////////////////////////// - Set existingEmails = new HashSet<>(); - QueryOutput existingContacts = new QueryAction().execute( - new QueryInput(Contact.TABLE_NAME)); - for(QRecord record : existingContacts.getRecords()) - { - String email = record.getValueString("email"); - if(StringUtils.hasContent(email)) - { - existingEmails.add(email.toLowerCase()); - } - } - - int importedCount = 0; - int skippedCount = 0; - int errorCount = 0; - - for(QRecord inputRecord : inputRecords) - { - String firstName = inputRecord.getValueString("firstName"); - String lastName = inputRecord.getValueString("lastName"); - String email = inputRecord.getValueString("email"); - - /////////////////////////////////////////// - // validate required fields // - /////////////////////////////////////////// - if(!StringUtils.hasContent(firstName) || !StringUtils.hasContent(lastName) || !StringUtils.hasContent(email)) - { - errorCount++; - continue; - } - - /////////////////////////////////////////// - // check for duplicate by email // - /////////////////////////////////////////// - if(existingEmails.contains(email.toLowerCase())) - { - skippedCount++; - continue; - } - - /////////////////////////////////////////// - // insert the new contact // - /////////////////////////////////////////// - QRecord newContact = new QRecord() - .withValue("firstName", firstName) - .withValue("lastName", lastName) - .withValue("email", email) - .withValue("phone", inputRecord.getValueString("phone")) - .withValue("ownerUserId", sessionUserId); - - new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecord(newContact)); - - existingEmails.add(email.toLowerCase()); - - importedCount++; - } - - output.addValue("importedCount", importedCount); - output.addValue("skippedCount", skippedCount); - output.addValue("errorCount", errorCount); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java index b8977ea..06540fb 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java @@ -50,50 +50,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for merging two companies into one. + ** Backend operation used by the streamed CRM actions for merging two companies into one. *******************************************************************************/ -public class MergeCompaniesProcess implements BackendStep, MetaDataProducerInterface +public class MergeCompaniesProcess implements BackendStep { public static final String NAME = "crm_merge_companies"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Merge Companies") - .withTableName(Company.TABLE_NAME) - .withIcon(new QIcon().withName("merge")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(MergeCompaniesProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("primaryCompanyId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("secondaryCompanyId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the merge companies process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java index ccf8407..f1746af 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java @@ -52,50 +52,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for merging two contacts into one. + ** Backend operation used by the streamed CRM actions for merging two contacts into one. *******************************************************************************/ -public class MergeContactsProcess implements BackendStep, MetaDataProducerInterface +public class MergeContactsProcess implements BackendStep { public static final String NAME = "crm_merge_contacts"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Merge Contacts") - .withTableName(Contact.TABLE_NAME) - .withIcon(new QIcon().withName("merge")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(MergeContactsProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("primaryContactId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("secondaryContactId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the merge contacts process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java index fe721da..b510c3a 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java @@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.EmailListMember; import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus; @@ -47,54 +46,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; /******************************************************************************* - ** MetaDataProducer + BackendStep to subscribe an email address to a list. + ** Backend operation used by the streamed CRM actions to subscribe an email address to a list. ** Handles: new subscription, resubscription after unsub, and no-op for ** existing active subscribers. *******************************************************************************/ -public class SubscribeToListProcess implements BackendStep, MetaDataProducerInterface +public class SubscribeToListProcess implements BackendStep { public static final String NAME = "crm_subscribe_to_list"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Subscribe to List") - .withIcon(new QIcon().withName("person_add")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(SubscribeToListProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("firstName", QFieldType.STRING), - new QFieldMetaData("subscriptionSource", QFieldType.STRING) - ))) - )); - } - - - /******************************************************************************* ** Execute the subscription logic. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java index 86e2237..c123044 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java @@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.core.model.EmailListMember; import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; @@ -44,51 +43,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep to unsubscribe an email from a list. + ** Backend operation used by the streamed CRM actions to unsubscribe an email from a list. ** Queries by emailListId + email, sets status to UNSUBSCRIBED and ** unsubscribedDate to now. *******************************************************************************/ -public class UnsubscribeFromListProcess implements BackendStep, MetaDataProducerInterface +public class UnsubscribeFromListProcess implements BackendStep { public static final String NAME = "crm_unsubscribe_from_list"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Unsubscribe from List") - .withIcon(new QIcon().withName("unsubscribe")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(UnsubscribeFromListProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the unsubscription. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcess.java index e2d6e3d..03836ef 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcess.java @@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.deals.processes; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealContact; import com.kingsrook.qbits.crm.deals.model.DealProduct; @@ -50,49 +49,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for cloning a deal and its child records. + ** Backend operation used by the streamed CRM actions for cloning a deal and its child records. *******************************************************************************/ -public class CloneDealProcess implements BackendStep, MetaDataProducerInterface +public class CloneDealProcess implements BackendStep { public static final String NAME = "crm_clone_deal"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Clone Deal") - .withTableName(Deal.TABLE_NAME) - .withIcon(new QIcon().withName("content_copy")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(CloneDealProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the clone deal process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java index e4026ec..33bbef0 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java @@ -30,7 +30,6 @@ package com.kingsrook.qbits.crm.deals.processes; import java.math.BigDecimal; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.LifecycleStage; import com.kingsrook.qbits.crm.deals.model.Deal; @@ -57,55 +56,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.utils.ValueUtils; /******************************************************************************* - ** MetaDataProducer + BackendStep for converting a lead (contact) into a + ** Backend operation used by the streamed CRM actions for converting a lead (contact) into a ** deal opportunity. Transitions the contact's lifecycle stage, creates ** a deal with the first pipeline stage, and links them via DealContact. *******************************************************************************/ -public class ConvertLeadProcess implements BackendStep, MetaDataProducerInterface +public class ConvertLeadProcess implements BackendStep { public static final String NAME = "crm_convert_lead"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Convert Lead") - .withTableName(Contact.TABLE_NAME) - .withIcon(new QIcon().withName("trending_up")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(ConvertLeadProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("pipelineId", QFieldType.INTEGER), - new QFieldMetaData("dealName", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("dealAmount", QFieldType.DECIMAL) - ))) - )); - } - - - /******************************************************************************* ** Execute the lead conversion process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcess.java index 4ca1daf..284d5a8 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcess.java @@ -50,55 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for adding or removing a contact on a deal. + ** Backend operation used by the streamed CRM actions for adding or removing a contact on a deal. ** ADD: inserts a DealContact row, with isPrimary enforcement. ** REMOVE: deletes the DealContact row matching dealId + contactId. *******************************************************************************/ -public class ManageContactOnDealProcess implements BackendStep, MetaDataProducerInterface +public class ManageContactOnDealProcess implements BackendStep { public static final String NAME = "crm_manage_contact_on_deal"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Manage Contact on Deal") - .withTableName(Deal.TABLE_NAME) - .withIcon(new QIcon().withName("person_add")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(ManageContactOnDealProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("action", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("contactRoleId", QFieldType.INTEGER), - new QFieldMetaData("isPrimary", QFieldType.BOOLEAN) - ))) - )); - } - - - /******************************************************************************* ** Execute the manage contact process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java index 136f205..726ffbf 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java @@ -50,55 +50,22 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp 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 com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; /******************************************************************************* - ** MetaDataProducer + BackendStep for moving a deal to a new pipeline stage. + ** Backend operation used by the streamed CRM actions for moving a deal to a new pipeline stage. ** Enforces stage-belongs-to-pipeline validation, required field gating, ** weighted amount recalculation, close-date stamping, and stage history ** tracking. *******************************************************************************/ -public class MoveDealStageProcess implements BackendStep, MetaDataProducerInterface +public class MoveDealStageProcess implements BackendStep { public static final String NAME = "crm_move_deal_stage"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Move Deal Stage") - .withTableName(Deal.TABLE_NAME) - .withIcon(new QIcon().withName("swap_horiz")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(MoveDealStageProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the stage move process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java index 7e68c98..5a8c82d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java @@ -31,7 +31,6 @@ package com.kingsrook.qbits.crm.deals.processes; import java.math.BigDecimal; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.List; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealStageHistory; import com.kingsrook.qbits.crm.deals.model.PipelineStage; @@ -47,52 +46,19 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp 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 com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for reopening a closed deal by moving it + ** Backend operation used by the streamed CRM actions for reopening a closed deal by moving it ** to an active pipeline stage. Validates the deal is currently closed, the ** target stage is not closed, clears close data, and logs the transition. *******************************************************************************/ -public class ReopenDealProcess implements BackendStep, MetaDataProducerInterface +public class ReopenDealProcess implements BackendStep { public static final String NAME = "crm_reopen_deal"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Reopen Deal") - .withTableName(Deal.TABLE_NAME) - .withIcon(new QIcon().withName("replay")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(ReopenDealProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the reopen process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java index a1e4167..6ac7337 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java @@ -27,7 +27,6 @@ package com.kingsrook.qbits.crm.deals.processes; -import java.util.List; import com.kingsrook.qbits.crm.core.model.Company; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; @@ -46,50 +45,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep for transferring ownership of contacts, + ** Backend operation used by the streamed CRM actions for transferring ownership of contacts, ** companies, and deals from one user to another. *******************************************************************************/ -public class TransferOwnershipProcess implements BackendStep, MetaDataProducerInterface +public class TransferOwnershipProcess implements BackendStep { public static final String NAME = "crm_transfer_ownership"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Transfer Ownership") - .withIcon(new QIcon().withName("swap_horiz")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(TransferOwnershipProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("fromUserId", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("toUserId", QFieldType.STRING).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the transfer ownership process. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java index 75c8d07..d9d0cbe 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java @@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.email.processes; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.List; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; import com.kingsrook.qbits.crm.email.model.EmailSequence; @@ -51,52 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep to enroll a contact in an email sequence. + ** Backend operation used by the streamed CRM actions to enroll a contact in an email sequence. ** Validates that the contact is not already enrolled, the sequence is active, ** and the contact has not opted out of email. *******************************************************************************/ -public class EnrollInSequenceProcess implements BackendStep, MetaDataProducerInterface +public class EnrollInSequenceProcess implements BackendStep { public static final String NAME = "crm_enroll_in_sequence"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Enroll in Sequence") - .withIcon(new QIcon().withName("playlist_add")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(EnrollInSequenceProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("sequenceId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("dealId", QFieldType.INTEGER) - ))) - )); - } - - - /******************************************************************************* ** Execute the enrollment logic. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java index adb4793..f6ea9f7 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java @@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; @@ -88,6 +89,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce { return new QProcessMetaData() .withName(NAME) + .withIsHidden(true) .withLabel("Process Sequence Steps") .withIcon(new QIcon().withName("play_circle")) // Scheduling is configured by the host application via ScheduledJob records @@ -109,29 +111,37 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce /////////////////////////////////////////////////////////// // query enrollments where status=ACTIVE and due now // /////////////////////////////////////////////////////////// - QueryOutput enrollmentQuery = new QueryAction().execute( - new QueryInput(SequenceEnrollment.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())) - .withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now())))); - int processedCount = 0; - - for(QRecord enrollmentRecord : enrollmentQuery.getRecords()) + Integer lastId = null; + Instant dueThrough = Instant.now(); + while(true) { - SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord); - - try + QQueryFilter filter = new QQueryFilter() + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())) + .withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, dueThrough)) + .withOrderBy(new QFilterOrderBy("id")).withLimit(200); + if(lastId != null) { - processEnrollment(enrollment); - processedCount++; + filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)); } - catch(Exception e) + List page = new QueryAction().execute(new QueryInput(SequenceEnrollment.TABLE_NAME).withFilter(filter)).getRecords(); + for(QRecord enrollmentRecord : page) { - ///////////////////////////////////////////// - // handle step failure // - ///////////////////////////////////////////// - handleFailure(enrollment, e.getMessage()); + SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord); + try + { + processEnrollment(enrollment); + processedCount++; + } + catch(Exception e) + { + handleFailure(enrollment, e.getMessage()); + } + lastId = enrollment.getId(); + } + if(page.size() < 200) + { + break; } } diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java deleted file mode 100644 index a8a38bf..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java +++ /dev/null @@ -1,236 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Process to send an email to a contact. Creates an Activity and an - ** EmailMessage record, respecting doNotEmail flags and optional template - ** lookups. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.email.processes; - - -import java.time.Instant; -import java.util.List; -import com.kingsrook.qbits.crm.CrmSessionUtils; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; -import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus; -import com.kingsrook.qbits.crm.email.model.EmailMessage; -import com.kingsrook.qbits.crm.email.model.EmailTemplate; -import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; -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.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -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.actions.tables.insert.InsertOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; - - -/******************************************************************************* - ** MetaDataProducer + BackendStep for sending an email to a CRM contact. - ** Validates doNotEmail, optionally loads a template, creates an Activity - ** record, and creates a tracked EmailMessage record. - *******************************************************************************/ -public class SendEmailProcess implements BackendStep, MetaDataProducerInterface -{ - public static final String NAME = "crm_send_email"; - - - - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Send Email") - .withTableName(Contact.TABLE_NAME) - .withIcon(new QIcon().withName("email")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(SendEmailProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("subject", QFieldType.STRING), - new QFieldMetaData("bodyHtml", QFieldType.STRING), - new QFieldMetaData("bodyText", QFieldType.STRING), - new QFieldMetaData("emailTemplateId", QFieldType.INTEGER), - new QFieldMetaData("dealId", QFieldType.INTEGER), - new QFieldMetaData("ccAddresses", QFieldType.STRING), - new QFieldMetaData("bccAddresses", QFieldType.STRING) - ))) - )); - } - - - - /******************************************************************************* - ** Execute the send-email process. - *******************************************************************************/ - @Override - public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException - { - Integer contactId = input.getValueInteger("contactId"); - String subject = input.getValueString("subject"); - String bodyHtml = input.getValueString("bodyHtml"); - String bodyText = input.getValueString("bodyText"); - Integer emailTemplateId = input.getValueInteger("emailTemplateId"); - Integer dealId = input.getValueInteger("dealId"); - String ccAddresses = input.getValueString("ccAddresses"); - String bccAddresses = input.getValueString("bccAddresses"); - String sessionUserId = CrmSessionUtils.getCurrentUserId(); - - /////////////////////////////////////////// - // Step 1: load and validate the contact // - /////////////////////////////////////////// - QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId); - if(contactRecord == null) - { - throw new QException("Contact not found: " + contactId); - } - - Contact contact = new Contact(contactRecord); - if(Boolean.TRUE.equals(contact.getDoNotEmail())) - { - throw new QUserFacingException("Cannot send email: contact has doNotEmail set to true."); - } - - ////////////////////////////////////////////////////// - // Step 2: if template provided, load and apply it // - ////////////////////////////////////////////////////// - if(emailTemplateId != null) - { - QRecord templateRecord = GetAction.execute(EmailTemplate.TABLE_NAME, emailTemplateId); - if(templateRecord != null) - { - EmailTemplate template = new EmailTemplate(templateRecord); - if(subject == null) - { - subject = template.getSubject(); - } - if(bodyHtml == null) - { - bodyHtml = template.getBodyHtml(); - } - if(bodyText == null) - { - bodyText = template.getBodyText(); - } - } - } - - ///////////////////////////////////////// - // Step 3: look up Email activity type // - ///////////////////////////////////////// - Integer emailTypeId = lookupEmailActivityTypeId(); - - ////////////////////////////////////////// - // Step 4: create the Activity record // - ////////////////////////////////////////// - Activity activity = new Activity() - .withActivityTypeId(emailTypeId) - .withSubject(subject) - .withContactId(contactId) - .withDealId(dealId) - .withDirection(CrmDirection.OUTBOUND.getPossibleValueId()) - .withOwnerUserId(sessionUserId) - .withIsCompleted(true); - - InsertOutput activityInsert = new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity(activity)); - Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id"); - - ////////////////////////////////////////////// - // Step 5: create the EmailMessage record // - ////////////////////////////////////////////// - String toAddress = contact.getEmail() != null ? contact.getEmail() : ""; - - EmailMessage emailMessage = new EmailMessage() - .withActivityId(activityId) - .withContactId(contactId) - .withFromAddress(sessionUserId) - .withFromName(sessionUserId) - .withToAddresses(toAddress) - .withCcAddresses(ccAddresses) - .withBccAddresses(bccAddresses) - .withSubject(subject != null ? subject : "") - .withBodyHtml(bodyHtml) - .withBodyText(bodyText) - .withDirection(CrmDirection.OUTBOUND.getPossibleValueId()) - .withStatus(CrmEmailStatus.SENT.getPossibleValueId()) - .withSentDate(Instant.now()) - .withIsTracked(true) - .withOpenCount(0) - .withClickCount(0) - .withEmailTemplateId(emailTemplateId); - - InsertOutput emailInsert = new InsertAction().execute( - new InsertInput(EmailMessage.TABLE_NAME).withRecordEntity(emailMessage)); - Integer emailMessageId = emailInsert.getRecords().get(0).getValueInteger("id"); - - output.addValue("emailMessageId", emailMessageId); - } - - - - /*************************************************************************** - ** Look up the "Email" activity type id. Returns null if not found. - ***************************************************************************/ - private Integer lookupEmailActivityTypeId() throws QException - { - QueryOutput queryOutput = new QueryAction().execute( - new QueryInput(ActivityType.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Email")))); - - if(!queryOutput.getRecords().isEmpty()) - { - return (queryOutput.getRecords().get(0).getValueInteger("id")); - } - - return (null); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java deleted file mode 100644 index 0a55d61..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java +++ /dev/null @@ -1,222 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Process to track email engagement events (open, click, bounce, spam, reply). - ** Updates the EmailMessage record counters and flags, and may set doNotEmail - ** on the linked contact for hard bounces and spam complaints. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.email.processes; - - -import java.time.Instant; -import java.util.List; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType; -import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; -import com.kingsrook.qbits.crm.email.model.EmailMessage; -import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; -import com.kingsrook.qqq.backend.core.actions.tables.GetAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -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.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; - - -/******************************************************************************* - ** MetaDataProducer + BackendStep for tracking email engagement events. - ** Handles OPEN, CLICK, BOUNCE, SPAM, and REPLY event types. - *******************************************************************************/ -public class TrackEmailEventProcess implements BackendStep, MetaDataProducerInterface -{ - public static final String NAME = "crm_track_email_event"; - - - - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Track Email Event") - .withTableName(EmailMessage.TABLE_NAME) - .withIcon(new QIcon().withName("track_changes")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(TrackEmailEventProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("emailMessageId", QFieldType.INTEGER).withIsRequired(true), - new QFieldMetaData("eventType", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("bounceTypeId", QFieldType.INTEGER) - ))) - )); - } - - - - /******************************************************************************* - ** Execute the tracking event process. - *******************************************************************************/ - @Override - public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException - { - Integer emailMessageId = input.getValueInteger("emailMessageId"); - String eventType = input.getValueString("eventType"); - Integer bounceTypeId = input.getValueInteger("bounceTypeId"); - - ////////////////////////////////////////// - // Step 1: load the email message // - ////////////////////////////////////////// - QRecord emailRecord = GetAction.execute(EmailMessage.TABLE_NAME, emailMessageId); - if(emailRecord == null) - { - throw new QException("Email message not found: " + emailMessageId); - } - - EmailMessage email = new EmailMessage(emailRecord); - - ////////////////////////////////////////// - // Step 2: handle event type // - ////////////////////////////////////////// - QRecord updateRecord = new QRecord().withValue("id", emailMessageId); - - switch(eventType.toUpperCase()) - { - case "OPEN": - { - int currentOpenCount = email.getOpenCount() != null ? email.getOpenCount() : 0; - updateRecord.setValue("openCount", currentOpenCount + 1); - if(email.getFirstOpenedDate() == null) - { - updateRecord.setValue("firstOpenedDate", Instant.now()); - } - break; - } - - case "CLICK": - { - int currentClickCount = email.getClickCount() != null ? email.getClickCount() : 0; - updateRecord.setValue("clickCount", currentClickCount + 1); - if(email.getFirstClickedDate() == null) - { - updateRecord.setValue("firstClickedDate", Instant.now()); - } - break; - } - - case "BOUNCE": - { - updateRecord.setValue("bounceType", bounceTypeId); - - ///////////////////////////////////////////////////// - // if hard bounce, set contact doNotEmail to true // - ///////////////////////////////////////////////////// - if(bounceTypeId != null && bounceTypeId.equals(CrmBounceType.HARD.getPossibleValueId())) - { - setContactDoNotEmail(email); - } - break; - } - - case "SPAM": - { - updateRecord.setValue("isSpamComplaint", true); - setContactDoNotEmail(email); - break; - } - - case "REPLY": - { - ////////////////////////////////////////////////////////////// - // if email is linked to a sequence enrollment, mark REPLIED // - ////////////////////////////////////////////////////////////// - if(email.getSequenceEnrollmentId() != null) - { - QRecord enrollmentUpdate = new QRecord() - .withValue("id", email.getSequenceEnrollmentId()) - .withValue("status", CrmEnrollmentStatus.REPLIED.getPossibleValueId()); - - new UpdateAction().execute( - new UpdateInput("crm_sequence_enrollment").withRecord(enrollmentUpdate)); - } - break; - } - - default: - throw new QException("Unknown event type: " + eventType); - } - - new UpdateAction().execute( - new UpdateInput(EmailMessage.TABLE_NAME).withRecord(updateRecord)); - } - - - - /*************************************************************************** - ** Finds the contact linked to an email message (via activity) and sets - ** doNotEmail = true. Looks up by the toAddresses field. - ***************************************************************************/ - private void setContactDoNotEmail(EmailMessage email) throws QException - { - if(email.getToAddresses() == null) - { - return; - } - - QueryOutput contactQuery = new QueryAction().execute( - new QueryInput(Contact.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("email", QCriteriaOperator.EQUALS, email.getToAddresses())))); - - if(!contactQuery.getRecords().isEmpty()) - { - QRecord contactUpdate = new QRecord() - .withValue("id", contactQuery.getRecords().get(0).getValueInteger("id")) - .withValue("doNotEmail", true); - - new UpdateAction().execute( - new UpdateInput(Contact.TABLE_NAME).withRecord(contactUpdate)); - } - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java index 1575600..349e5f0 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java @@ -27,7 +27,6 @@ package com.kingsrook.qbits.crm.email.processes; import java.time.Instant; -import java.util.List; import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; import com.kingsrook.qbits.crm.email.model.SequenceEnrollment; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; @@ -41,49 +40,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; /******************************************************************************* - ** MetaDataProducer + BackendStep to unenroll a contact from an email sequence. + ** Backend operation used by the streamed CRM actions to unenroll a contact from an email sequence. ** Sets status to UNENROLLED and records the unenrolled date. *******************************************************************************/ -public class UnenrollFromSequenceProcess implements BackendStep, MetaDataProducerInterface +public class UnenrollFromSequenceProcess implements BackendStep { public static final String NAME = "crm_unenroll_from_sequence"; - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Unenroll from Sequence") - .withIcon(new QIcon().withName("playlist_remove")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(UnenrollFromSequenceProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("enrollmentId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the unenrollment logic. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java index 4109134..8e186ec 100644 --- a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java @@ -55,25 +55,16 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; /******************************************************************************* - ** MetaDataProducer + BackendStep to auto-assign a contact or deal to a user + ** Backend operation used by the streamed CRM actions to auto-assign a contact or deal to a user ** based on configured assignment rules. Evaluates rules in sortOrder; first ** match wins. Supports SPECIFIC_USER, ROUND_ROBIN, and LEAST_ACTIVE strategies. *******************************************************************************/ -public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface +public class AutoAssignProcess implements BackendStep { private static final QLogger LOG = QLogger.getLogger(AutoAssignProcess.class); @@ -81,29 +72,6 @@ public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface - /******************************************************************************* - ** Produce the process metadata. - *******************************************************************************/ - @Override - public QProcessMetaData produce(QInstance qInstance) throws QException - { - return new QProcessMetaData() - .withName(NAME) - .withLabel("Auto Assign") - .withIcon(new QIcon().withName("assignment_ind")) - .withStepList(List.of( - new QBackendStepMetaData() - .withName("execute") - .withCode(new QCodeReference(AutoAssignProcess.class)) - .withInputData(new QFunctionInputMetaData().withFieldList(List.of( - new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true), - new QFieldMetaData("recordId", QFieldType.INTEGER).withIsRequired(true) - ))) - )); - } - - - /******************************************************************************* ** Execute the assignment logic. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java index 9e06e56..d6c694d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java @@ -80,6 +80,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer { return new QProcessMetaData() .withName(NAME) + .withIsHidden(true) .withLabel("Calculate Lead Scores") .withIcon(new QIcon().withName("score")) // Scheduling is configured by the host application via ScheduledJob records @@ -114,7 +115,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer /////////////////////////////////////////////////////////////// // paginate through all contacts to avoid OOM on large CRMs // /////////////////////////////////////////////////////////////// - int pageSize = 500; + int pageSize = 200; int updatedCount = 0; int totalContacts = 0; Integer lastId = null; @@ -140,38 +141,12 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer for(QRecord contactRecord : contactsQuery.getRecords()) { - Contact contact = new Contact(contactRecord); totalContacts++; - int newScore = 0; - - /////////////////////////////////////////////// - // evaluate each rule against the contact // - /////////////////////////////////////////////// - for(LeadScoreRule rule : rules) + if(recalculate(contactRecord, rules)) { - if(evaluateRule(rule, contactRecord)) - { - newScore += rule.getScoreAdjustment(); - } - } - - /////////////////////////////////////////////// - // update only if score changed // - /////////////////////////////////////////////// - Integer currentScore = contact.getLeadScore() != null ? contact.getLeadScore() : 0; - if(!Objects.equals(currentScore, newScore)) - { - QRecord updateRecord = new QRecord() - .withValue("id", contact.getId()) - .withValue("leadScore", newScore); - - new UpdateAction().execute( - new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord)); - updatedCount++; } - - lastId = contact.getId(); + lastId = contactRecord.getValueInteger("id"); } ///////////////////////////////////////////// @@ -190,6 +165,41 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer + /******************************************************************************* + ** Recalculate only the selected contact using the same rules as the job. + *******************************************************************************/ + public void recalculate(QRecord contact) throws QException + { + List rules = new QueryAction().execute(new QueryInput(LeadScoreRule.TABLE_NAME) + .withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)) + .withOrderBy(new QFilterOrderBy("sortOrder")))).getRecords().stream().map(LeadScoreRule::new).toList(); + recalculate(contact, rules); + } + + /******************************************************************************* + ** Write only changed scores; return whether a write was needed. + *******************************************************************************/ + private boolean recalculate(QRecord contact, List rules) throws QException + { + int score = 0; + for(LeadScoreRule rule : rules) + { + if(evaluateRule(rule, contact)) + { + score += rule.getScoreAdjustment(); + } + } + Integer current = contact.getValueInteger("leadScore"); + if(Objects.equals(current == null ? 0 : current, score)) + { + return (false); + } + new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME) + .withRecord(new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score))); + return (true); + } + + /*************************************************************************** ** Evaluate a single rule against a contact record. Supports dot-notation ** field paths for related entities (e.g., "company.industry"). diff --git a/src/main/java/com/kingsrook/qbits/crm/security/CrmPermissions.java b/src/main/java/com/kingsrook/qbits/crm/security/CrmPermissions.java index cd8d462..d846854 100644 --- a/src/main/java/com/kingsrook/qbits/crm/security/CrmPermissions.java +++ b/src/main/java/com/kingsrook/qbits/crm/security/CrmPermissions.java @@ -57,7 +57,8 @@ public final class CrmPermissions private static final Set MANAGEMENT_PROCESSES = Set.of( "crm_merge_contacts", "crm_merge_companies", "crm_bulk_assign_owner", "crm_subscribe_to_list", "crm_unsubscribe_from_list", "crm_enroll_in_sequence", - "crm_unenroll_from_sequence", "crm_process_sequence_steps"); + "crm_unenroll_from_sequence", "crm_process_sequence_steps", "crm_assign_company_owner", + "crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence"); diff --git a/src/test/java/com/kingsrook/qbits/crm/BaseTest.java b/src/test/java/com/kingsrook/qbits/crm/BaseTest.java index 25f0857..c002c67 100644 --- a/src/test/java/com/kingsrook/qbits/crm/BaseTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/BaseTest.java @@ -124,4 +124,37 @@ public class BaseTest CrmQBitProducer.GROUP_ID + ":" + CrmQBitProducer.ARTIFACT_ID); } + /******************************************************************************* + ** Existing operation regressions run helpers directly. CrmActionsTest covers ETL. + *******************************************************************************/ + protected com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput runBusinessStep( + com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput input) throws QException + { + com.kingsrook.qqq.backend.core.actions.processes.BackendStep step; + step = switch(input.getProcessName()) + { + case "crm_auto_assign" -> new com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess(); + case "crm_enroll_in_sequence" -> new com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess(); + case "crm_unenroll_from_sequence" -> new com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess(); + case "crm_clone_deal" -> new com.kingsrook.qbits.crm.deals.processes.CloneDealProcess(); + case "crm_convert_lead" -> new com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess(); + case "crm_manage_contact_on_deal" -> new com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess(); + case "crm_move_deal_stage" -> new com.kingsrook.qbits.crm.deals.processes.MoveDealStageProcess(); + case "crm_reopen_deal" -> new com.kingsrook.qbits.crm.deals.processes.ReopenDealProcess(); + case "crm_transfer_ownership" -> new com.kingsrook.qbits.crm.deals.processes.TransferOwnershipProcess(); + case "crm_convert_form_to_contact" -> new com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess(); + case "crm_detect_duplicates" -> new com.kingsrook.qbits.crm.core.processes.DetectDuplicatesProcess(); + case "crm_gdpr_anonymize_contact" -> new com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess(); + case "crm_merge_companies" -> new com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess(); + case "crm_merge_contacts" -> new com.kingsrook.qbits.crm.core.processes.MergeContactsProcess(); + case "crm_subscribe_to_list" -> new com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess(); + case "crm_unsubscribe_from_list" -> new com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess(); + case "crm_log_activity" -> new com.kingsrook.qbits.crm.activities.processes.LogActivityProcess(); + default -> throw (new IllegalArgumentException(input.getProcessName())); + }; + com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput(); + step.run(input, output); + return (output); + } + } diff --git a/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java b/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java index eca3426..4c9bf83 100644 --- a/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/ContingentPossibleValuesTest.java @@ -48,7 +48,6 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; -import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; @@ -103,7 +102,7 @@ class ContingentPossibleValuesTest extends BaseTest /******************************************************************************* - ** Optional company links and backend-only process inputs stay unrestricted. + ** Optional activity links stay unrestricted; logging has standard ETL screens. *******************************************************************************/ @Test void testSkippedFields() @@ -112,8 +111,8 @@ class ContingentPossibleValuesTest extends BaseTest assertThat(activity.getField("companyId").getIsRequired()).isFalse(); assertThat(activity.getField("contactId").getPossibleValueSourceFilter()).isNull(); assertThat(activity.getField("dealId").getPossibleValueSourceFilter()).isNull(); - assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getStepList()) - .allMatch(step -> step instanceof QBackendStepMetaData); + assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getFrontendStep("details")).isNotNull(); + assertThat(QContext.getQInstance().getProcess(LogActivityProcess.NAME).getFrontendStep("review")).isNotNull(); } diff --git a/src/test/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcessTest.java deleted file mode 100644 index 1070125..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcessTest.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for FireRemindersProcess. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.activities.processes; - - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; -import com.kingsrook.qqq.backend.core.actions.tables.GetAction; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - - -/******************************************************************************* - ** Tests for firing reminders: create activity with reminder, run process, - ** verify the reminder flag is cleared. - *******************************************************************************/ -class FireRemindersProcessTest extends BaseTest -{ - - /******************************************************************************* - ** Create an activity with a due reminder and verify it gets cleared. - *******************************************************************************/ - @Test - void testFireReminders() throws QException - { - Integer typeId = insertActivityType("Meeting"); - - /////////////////////////////////////// - // create activity with past reminder // - /////////////////////////////////////// - InsertOutput activityInsert = new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(typeId) - .withSubject("Team Meeting") - .withIsReminderSet(true) - .withReminderDate(Instant.now().minus(1, ChronoUnit.HOURS)) - .withIsCompleted(false) - .withOwnerUserId("user-001"))); - Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id"); - - //////////////////////////////////// - // run the process // - //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(FireRemindersProcess.NAME); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(1, processOutput.getValues().get("firedCount")); - - ///////////////////////////////////////////// - // verify the reminder flag was cleared // - ///////////////////////////////////////////// - Activity updated = new Activity(GetAction.execute(Activity.TABLE_NAME, activityId)); - assertFalse(updated.getIsReminderSet()); - } - - - - /******************************************************************************* - ** Future reminders should not be fired. - *******************************************************************************/ - @Test - void testFutureReminderNotFired() throws QException - { - Integer typeId = insertActivityType("Call"); - - new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(typeId) - .withSubject("Future Call") - .withIsReminderSet(true) - .withReminderDate(Instant.now().plus(1, ChronoUnit.DAYS)) - .withIsCompleted(false) - .withOwnerUserId("user-001"))); - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(FireRemindersProcess.NAME); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(0, processOutput.getValues().get("firedCount")); - } - - - - /*************************************************************************** - ** Helper: insert an ActivityType and return its id. - ***************************************************************************/ - private Integer insertActivityType(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(ActivityType.TABLE_NAME) - .withRecordEntity(new ActivityType() - .withName(name) - .withIconName("alarm") - .withIsSystem(false) - .withSortOrder(1) - .withIsActive(true))); - return (output.getRecords().get(0).getValueInteger("id")); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcessTest.java index 281fbc3..db484e4 100644 --- a/src/test/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/activities/processes/LogActivityProcessTest.java @@ -29,12 +29,11 @@ package com.kingsrook.qbits.crm.activities.processes; import com.kingsrook.qbits.crm.BaseTest; import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; @@ -65,7 +64,7 @@ class LogActivityProcessTest extends BaseTest //////////////////////////////////// // run the LogActivity process // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(LogActivityProcess.NAME); processInput.addValue("activityTypeId", activityTypeId); processInput.addValue("subject", "Test call with client"); @@ -73,7 +72,7 @@ class LogActivityProcessTest extends BaseTest processInput.addValue("contactId", 42); processInput.addValue("durationMinutes", 15); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); //////////////////////////////////////////// // verify the activity record was created // @@ -102,12 +101,12 @@ class LogActivityProcessTest extends BaseTest { Integer activityTypeId = insertActivityType("Note"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(LogActivityProcess.NAME); processInput.addValue("activityTypeId", activityTypeId); processInput.addValue("subject", "Quick note"); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); QueryOutput queryOutput = new QueryAction().execute( new QueryInput(Activity.TABLE_NAME)); diff --git a/src/test/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcessTest.java deleted file mode 100644 index 0d67c2c..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcessTest.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for ProcessOverdueTasksProcess. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.activities.processes; - - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; -import com.kingsrook.qqq.backend.core.actions.tables.GetAction; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - - -/******************************************************************************* - ** Tests for processing overdue tasks. - *******************************************************************************/ -class ProcessOverdueTasksProcessTest extends BaseTest -{ - - /******************************************************************************* - ** Create an overdue task, run process, verify it was marked. - *******************************************************************************/ - @Test - void testProcessOverdueTasks() throws QException - { - /////////////////////////////////////// - // set up prerequisites // - /////////////////////////////////////// - Integer taskTypeId = insertActivityType("Task"); - - /////////////////////////////////////// - // create an overdue incomplete task // - /////////////////////////////////////// - InsertOutput activityInsert = new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(taskTypeId) - .withSubject("Overdue task") - .withDueDate(Instant.now().minus(2, ChronoUnit.DAYS)) - .withIsCompleted(false) - .withOwnerUserId("user-001"))); - Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id"); - - //////////////////////////////////// - // run the process // - //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(ProcessOverdueTasksProcess.NAME); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(1, processOutput.getValues().get("processedCount")); - - ///////////////////////////////////////////// - // verify the task description was updated // - ///////////////////////////////////////////// - Activity updated = new Activity(GetAction.execute(Activity.TABLE_NAME, activityId)); - assertNotNull(updated.getDescription()); - assertTrue(updated.getDescription().contains("[OVERDUE]")); - } - - - - /******************************************************************************* - ** Non-overdue tasks should not be processed. - *******************************************************************************/ - @Test - void testNoOverdueTasks() throws QException - { - Integer taskTypeId = insertActivityType("Task"); - - /////////////////////////////////////// - // create a future task // - /////////////////////////////////////// - new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(taskTypeId) - .withSubject("Future task") - .withDueDate(Instant.now().plus(5, ChronoUnit.DAYS)) - .withIsCompleted(false) - .withOwnerUserId("user-001"))); - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(ProcessOverdueTasksProcess.NAME); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(0, processOutput.getValues().get("processedCount")); - } - - - - /*************************************************************************** - ** Helper: insert an ActivityType and return its id. - ***************************************************************************/ - private Integer insertActivityType(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(ActivityType.TABLE_NAME) - .withRecordEntity(new ActivityType() - .withName(name) - .withIconName("task") - .withIsSystem(false) - .withSortOrder(1) - .withIsActive(true))); - return (output.getRecords().get(0).getValueInteger("id")); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcessTest.java deleted file mode 100644 index 7bf5fa7..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcessTest.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for BulkAssignOwnerProcess. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.core.processes; - - -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -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.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; - - -/******************************************************************************* - ** Tests that BulkAssignOwnerProcess sets ownerUserId on multiple records. - *******************************************************************************/ -class BulkAssignOwnerProcessTest extends BaseTest -{ - - /******************************************************************************* - ** Assign owner on multiple contacts via the transform step. - *******************************************************************************/ - @Test - void testBulkAssignOwner() throws QException - { - ////////////////////////////////// - // insert two contacts // - ////////////////////////////////// - new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecordEntity( - new Contact() - .withFirstName("Alice").withLastName("A").withOwnerUserId("old-owner").withEmail("alice@test.com"))); - new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecordEntity( - new Contact() - .withFirstName("Bob").withLastName("B").withOwnerUserId("old-owner").withEmail("bob@test.com"))); - - ////////////////////////////////////////////////////////// - // query back the records // - ////////////////////////////////////////////////////////// - QueryOutput queryBefore = new QueryAction().execute( - new QueryInput(Contact.TABLE_NAME)); - assertEquals(2, queryBefore.getRecords().size()); - - ////////////////////////////////////////////////////////// - // run the transform step directly // - ////////////////////////////////////////////////////////// - BulkAssignOwnerProcess.BulkAssignOwnerTransformStep step = new BulkAssignOwnerProcess.BulkAssignOwnerTransformStep(); - - RunBackendStepInput stepInput = new RunBackendStepInput(); - stepInput.addValue("newOwnerUserId", "new-owner"); - stepInput.setRecords(queryBefore.getRecords()); - - RunBackendStepOutput stepOutput = new RunBackendStepOutput(); - step.runOnePage(stepInput, stepOutput); - - ////////////////////////////////////////////////////////// - // verify all records had ownerUserId updated // - ////////////////////////////////////////////////////////// - assertEquals(2, stepOutput.getRecords().size()); - for(QRecord record : stepOutput.getRecords()) - { - assertEquals("new-owner", record.getValueString("ownerUserId")); - } - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java index 22f4a7b..751591a 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/ConvertFormToContactProcessTest.java @@ -33,12 +33,11 @@ import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.FormSubmission; import com.kingsrook.qbits.crm.core.model.LeadSource; import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -78,11 +77,11 @@ class ConvertFormToContactProcessTest extends BaseTest /////////////////////////////////////////////// // run the conversion process // /////////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ConvertFormToContactProcess.NAME); processInput.addValue("formSubmissionId", formId); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); /////////////////////////////////////////////// // verify a new contact was created // @@ -154,11 +153,11 @@ class ConvertFormToContactProcessTest extends BaseTest ///////////////////////////////////////////// // run conversion // ///////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ConvertFormToContactProcess.NAME); processInput.addValue("formSubmissionId", formId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); ///////////////////////////////////////////// // verify no new contact was created // diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcessTest.java index cc0c144..a9d25d4 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/DetectDuplicatesProcessTest.java @@ -29,11 +29,10 @@ package com.kingsrook.qbits.crm.core.processes; import com.kingsrook.qbits.crm.BaseTest; import com.kingsrook.qbits.crm.core.model.Company; import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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 org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -62,11 +61,11 @@ class DetectDuplicatesProcessTest extends BaseTest //////////////////////////////////// // run detect duplicates // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(DetectDuplicatesProcess.NAME); processInput.addValue("entityType", "CONTACT"); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals(1, processOutput.getValues().get("duplicateGroupCount")); assertEquals(1, processOutput.getRecords().size()); @@ -85,11 +84,11 @@ class DetectDuplicatesProcessTest extends BaseTest insertCompany("Corp B", "dupe.com"); insertCompany("Corp C", "unique.com"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(DetectDuplicatesProcess.NAME); processInput.addValue("entityType", "COMPANY"); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals(1, processOutput.getValues().get("duplicateGroupCount")); } @@ -105,11 +104,11 @@ class DetectDuplicatesProcessTest extends BaseTest insertContact("one@example.com", "One", "User"); insertContact("two@example.com", "Two", "User"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(DetectDuplicatesProcess.NAME); processInput.addValue("entityType", "CONTACT"); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals(0, processOutput.getValues().get("duplicateGroupCount")); assertTrue(processOutput.getRecords().isEmpty()); diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java index 550f993..8fa4a66 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java @@ -31,13 +31,12 @@ import com.kingsrook.qbits.crm.core.model.ConsentRecord; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; @@ -69,11 +68,11 @@ class GdprAnonymizeContactProcessTest extends BaseTest //////////////////////////////////// // run the anonymize process // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(GdprAnonymizeContactProcess.NAME); processInput.addValue("contactId", contactId); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertTrue((Boolean) processOutput.getValues().get("anonymized")); diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java deleted file mode 100644 index 6dde653..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for ImportContactsProcess. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.core.processes; - - -import java.util.ArrayList; -import java.util.List; -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; - - -/******************************************************************************* - ** Tests for importing contacts: new inserts, duplicate skipping, validation. - *******************************************************************************/ -class ImportContactsProcessTest extends BaseTest -{ - - /******************************************************************************* - ** Import new contacts and verify they are created with audit logs. - *******************************************************************************/ - @Test - void testImportNewContacts() throws QException - { - List records = new ArrayList<>(); - records.add(new QRecord() - .withValue("firstName", "Alice") - .withValue("lastName", "Smith") - .withValue("email", "alice@example.com")); - records.add(new QRecord() - .withValue("firstName", "Bob") - .withValue("lastName", "Jones") - .withValue("email", "bob@example.com")); - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(ImportContactsProcess.NAME); - processInput.setRecords(records); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(2, processOutput.getValues().get("importedCount")); - assertEquals(0, processOutput.getValues().get("skippedCount")); - assertEquals(0, processOutput.getValues().get("errorCount")); - - QueryOutput contactQuery = new QueryAction().execute( - new QueryInput(Contact.TABLE_NAME)); - assertEquals(2, contactQuery.getRecords().size()); - - } - - - - /******************************************************************************* - ** Test duplicate skipping: pre-existing contact with same email is skipped. - *******************************************************************************/ - @Test - void testImportWithDuplicates() throws QException - { - ///////////////////////////////////////////// - // insert existing contact // - ///////////////////////////////////////////// - new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecordEntity( - new Contact() - .withFirstName("Existing") - .withLastName("User") - .withEmail("alice@example.com") - .withOwnerUserId("user-001"))); - - List records = new ArrayList<>(); - records.add(new QRecord() - .withValue("firstName", "Alice") - .withValue("lastName", "Smith") - .withValue("email", "alice@example.com")); // duplicate - records.add(new QRecord() - .withValue("firstName", "Charlie") - .withValue("lastName", "Brown") - .withValue("email", "charlie@example.com")); // new - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(ImportContactsProcess.NAME); - processInput.setRecords(records); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(1, processOutput.getValues().get("importedCount")); - assertEquals(1, processOutput.getValues().get("skippedCount")); - assertEquals(0, processOutput.getValues().get("errorCount")); - } - - - - /******************************************************************************* - ** Test validation: records missing required fields are counted as errors. - *******************************************************************************/ - @Test - void testImportWithValidationErrors() throws QException - { - List records = new ArrayList<>(); - records.add(new QRecord() - .withValue("firstName", "") - .withValue("lastName", "NoFirst") - .withValue("email", "nofirst@example.com")); // missing firstName - records.add(new QRecord() - .withValue("firstName", "Valid") - .withValue("lastName", "User") - .withValue("email", "valid@example.com")); - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(ImportContactsProcess.NAME); - processInput.setRecords(records); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - assertEquals(1, processOutput.getValues().get("importedCount")); - assertEquals(0, processOutput.getValues().get("skippedCount")); - assertEquals(1, processOutput.getValues().get("errorCount")); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcessTest.java index 1095170..9bc1820 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcessTest.java @@ -33,11 +33,10 @@ import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import org.junit.jupiter.api.Test; @@ -78,12 +77,12 @@ class MergeCompaniesProcessTest extends BaseTest //////////////////////////////////// // run merge process // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MergeCompaniesProcess.NAME); processInput.addValue("primaryCompanyId", primaryId); processInput.addValue("secondaryCompanyId", secondaryId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); ///////////////////////////////////////////// // verify secondary company is deleted // diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcessTest.java index 38333d9..171752c 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcessTest.java @@ -30,12 +30,11 @@ import com.kingsrook.qbits.crm.BaseTest; import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.activities.model.ActivityType; import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -89,12 +88,12 @@ class MergeContactsProcessTest extends BaseTest //////////////////////////////////// // run merge process // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MergeContactsProcess.NAME); processInput.addValue("primaryContactId", primaryId); processInput.addValue("secondaryContactId", secondaryId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); ///////////////////////////////////////////// // verify secondary contact is deleted // diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java index a816a34..5a19eb0 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcessTest.java @@ -31,12 +31,11 @@ import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.EmailList; import com.kingsrook.qbits.crm.core.model.EmailListMember; import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; @@ -61,14 +60,14 @@ class SubscribeToListProcessTest extends BaseTest { Integer listId = insertEmailList("Newsletter"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(SubscribeToListProcess.NAME); processInput.addValue("email", "new@example.com"); processInput.addValue("emailListId", listId); processInput.addValue("firstName", "Alice"); processInput.addValue("subscriptionSource", "website"); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals("SUBSCRIBED", processOutput.getValues().get("action")); @@ -111,12 +110,12 @@ class SubscribeToListProcessTest extends BaseTest /////////////////////////////////////////// // run subscribe process // /////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(SubscribeToListProcess.NAME); processInput.addValue("email", "unsub@example.com"); processInput.addValue("emailListId", listId); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals("RESUBSCRIBED", processOutput.getValues().get("action")); @@ -155,12 +154,12 @@ class SubscribeToListProcessTest extends BaseTest /////////////////////////////////////////// // run subscribe process // /////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(SubscribeToListProcess.NAME); processInput.addValue("email", "active@example.com"); processInput.addValue("emailListId", listId); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals("NOOP", processOutput.getValues().get("action")); @@ -197,12 +196,12 @@ class SubscribeToListProcessTest extends BaseTest /////////////////////////////////////////// // subscribe with the same email // /////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(SubscribeToListProcess.NAME); processInput.addValue("email", "linked@example.com"); processInput.addValue("emailListId", listId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); /////////////////////////////////////////// // verify contactId is set // diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcessTest.java index 17fcefe..fa2c0b1 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/CloneDealProcessTest.java @@ -33,13 +33,12 @@ import com.kingsrook.qbits.crm.deals.model.DealContact; import com.kingsrook.qbits.crm.deals.model.DealProduct; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -87,11 +86,11 @@ class CloneDealProcessTest extends BaseTest //////////////////////////////////// // run the CloneDeal process // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(CloneDealProcess.NAME); processInput.addValue("dealId", dealId); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); Integer newDealId = (Integer) processOutput.getValues().get("dealId"); ///////////////////////////////////////////// diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcessTest.java index 725186f..35c9957 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcessTest.java @@ -34,13 +34,12 @@ import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealContact; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -83,13 +82,13 @@ class ConvertLeadProcessTest extends BaseTest /////////////////////////////////////////// // run the conversion process // /////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ConvertLeadProcess.NAME); processInput.addValue("contactId", contactId); processInput.addValue("dealName", "Jane's Opportunity"); processInput.addValue("dealAmount", new BigDecimal("25000")); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); Integer dealId = processOutput.getValueInteger("dealId"); assertNotNull(dealId); @@ -143,13 +142,13 @@ class ConvertLeadProcessTest extends BaseTest Integer contactId = insertContact("Bob", "Lead", "bob@example.com", null); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ConvertLeadProcess.NAME); processInput.addValue("contactId", contactId); processInput.addValue("pipelineId", customPipelineId); processInput.addValue("dealName", "Bob's Custom Deal"); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); Integer dealId = processOutput.getValueInteger("dealId"); Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); @@ -171,12 +170,12 @@ class ConvertLeadProcessTest extends BaseTest Integer contactId = insertContact("Alice", "NoAmount", "alice@example.com", null); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ConvertLeadProcess.NAME); processInput.addValue("contactId", contactId); processInput.addValue("dealName", "Alice Deal"); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); Integer dealId = processOutput.getValueInteger("dealId"); Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcessTest.java index ccd3cce..160358e 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ManageContactOnDealProcessTest.java @@ -34,13 +34,12 @@ import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealContact; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -71,14 +70,14 @@ class ManageContactOnDealProcessTest extends BaseTest Integer dealId = setupDeal(); Integer contactId = insertContact("Alice", "Test"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ManageContactOnDealProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("contactId", contactId); processInput.addValue("action", "ADD"); processInput.addValue("isPrimary", false); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); /////////////////////////////////////////// // verify DealContact junction created // @@ -182,14 +181,14 @@ class ManageContactOnDealProcessTest extends BaseTest Integer dealId = setupDeal(); Integer contactId = insertContact("Not", "Linked"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ManageContactOnDealProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("contactId", contactId); processInput.addValue("action", "REMOVE"); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } @@ -199,7 +198,7 @@ class ManageContactOnDealProcessTest extends BaseTest ***************************************************************************/ private void runManageContact(Integer dealId, Integer contactId, String action, Boolean isPrimary) throws QException { - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ManageContactOnDealProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("contactId", contactId); @@ -209,7 +208,7 @@ class ManageContactOnDealProcessTest extends BaseTest processInput.addValue("isPrimary", isPrimary); } - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); } diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java index 3fc3f0c..a2e4680 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java @@ -34,13 +34,12 @@ import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealStageHistory; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -85,12 +84,12 @@ class MoveDealStageProcessTest extends BaseTest /////////////////////////////////////////// // move to stage 2 // /////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MoveDealStageProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", stage2Id); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); /////////////////////////////////////////// // verify deal was updated // @@ -144,12 +143,12 @@ class MoveDealStageProcessTest extends BaseTest Integer dealId = insertDeal("Win Deal", pipelineId, stage1Id, new BigDecimal("5000")); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MoveDealStageProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", wonStageId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); Deal updatedDeal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); assertEquals(wonStageId, updatedDeal.getPipelineStageId()); @@ -175,12 +174,12 @@ class MoveDealStageProcessTest extends BaseTest Integer dealId = insertDeal("Lost Deal", pipelineId, stage1Id, new BigDecimal("8000")); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MoveDealStageProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", lostStageId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); Deal updatedDeal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); assertNotNull(updatedDeal.getActualCloseDate()); @@ -203,13 +202,13 @@ class MoveDealStageProcessTest extends BaseTest Integer dealId = insertDeal("Cross Pipeline Deal", pipeline1Id, stage1Id, new BigDecimal("1000")); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MoveDealStageProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", stage2Id); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } @@ -229,13 +228,13 @@ class MoveDealStageProcessTest extends BaseTest ////////////////////////////////////////////////////////////// Integer dealId = insertDeal("Gated Deal", pipelineId, stage1Id, new BigDecimal("1000")); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MoveDealStageProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", stage2Id); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } @@ -265,12 +264,12 @@ class MoveDealStageProcessTest extends BaseTest .withStageEnteredDate(Instant.now().minus(2, ChronoUnit.DAYS)))); Integer dealId = dealInsert.getRecords().get(0).getValueInteger("id"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(MoveDealStageProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", stage2Id); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); Deal updatedDeal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java index c707436..5489dca 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java @@ -35,13 +35,12 @@ import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealStageHistory; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -87,12 +86,12 @@ class ReopenDealProcessTest extends BaseTest /////////////////////////////////////////// // reopen the deal to Negotiation // /////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ReopenDealProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", activeStage); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); /////////////////////////////////////////// // verify deal was updated // @@ -155,13 +154,13 @@ class ReopenDealProcessTest extends BaseTest .withStageEnteredDate(Instant.now()))); Integer dealId = dealInsert.getRecords().get(0).getValueInteger("id"); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ReopenDealProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", otherStage); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } @@ -178,13 +177,13 @@ class ReopenDealProcessTest extends BaseTest Integer dealId = insertClosedDeal("Closed Deal", pipelineId, wonStage, new BigDecimal("5000"), null); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(ReopenDealProcess.NAME); processInput.addValue("dealId", dealId); processInput.addValue("targetStageId", lostStage); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcessTest.java index 040d098..916a8c2 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcessTest.java @@ -33,11 +33,10 @@ import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import org.junit.jupiter.api.Test; @@ -69,12 +68,12 @@ class TransferOwnershipProcessTest extends BaseTest //////////////////////////////////// // run transfer process // //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(TransferOwnershipProcess.NAME); processInput.addValue("fromUserId", "user-old"); processInput.addValue("toUserId", "user-new"); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); ///////////////////////////////////////////// // verify contact updated // diff --git a/src/test/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcessTest.java index 23a97cd..7b47525 100644 --- a/src/test/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcessTest.java @@ -33,13 +33,12 @@ import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType; import com.kingsrook.qbits.crm.email.model.EmailSequence; import com.kingsrook.qbits.crm.email.model.SequenceEnrollment; import com.kingsrook.qbits.crm.email.model.SequenceStep; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; @@ -67,12 +66,12 @@ class EnrollInSequenceProcessTest extends BaseTest Integer contactId = insertContact("Alice", "Test", false); insertStep(sequenceId, 1, CrmSequenceStepType.EMAIL.getId(), 1, 0); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(EnrollInSequenceProcess.NAME); processInput.addValue("sequenceId", sequenceId); processInput.addValue("contactId", contactId); - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); + RunBackendStepOutput processOutput = runBusinessStep(processInput); assertEquals("ENROLLED", processOutput.getValues().get("action")); @@ -106,23 +105,23 @@ class EnrollInSequenceProcessTest extends BaseTest ///////////////////////////////////////////// // first enrollment // ///////////////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(EnrollInSequenceProcess.NAME); processInput.addValue("sequenceId", sequenceId); processInput.addValue("contactId", contactId); - new RunProcessAction().execute(processInput); + runBusinessStep(processInput); ///////////////////////////////////////////// // second enrollment should throw // ///////////////////////////////////////////// - RunProcessInput processInput2 = new RunProcessInput(); + RunBackendStepInput processInput2 = new RunBackendStepInput(); processInput2.setProcessName(EnrollInSequenceProcess.NAME); processInput2.addValue("sequenceId", sequenceId); processInput2.addValue("contactId", contactId); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput2)); + runBusinessStep(processInput2)); } @@ -136,13 +135,13 @@ class EnrollInSequenceProcessTest extends BaseTest Integer sequenceId = insertSequence("DNE Test", true); Integer contactId = insertContact("Carol", "NoEmail", true); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(EnrollInSequenceProcess.NAME); processInput.addValue("sequenceId", sequenceId); processInput.addValue("contactId", contactId); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } @@ -156,13 +155,13 @@ class EnrollInSequenceProcessTest extends BaseTest Integer sequenceId = insertSequence("Inactive", false); Integer contactId = insertContact("Dave", "Test", false); - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(EnrollInSequenceProcess.NAME); processInput.addValue("sequenceId", sequenceId); processInput.addValue("contactId", contactId); assertThrows(QUserFacingException.class, () -> - new RunProcessAction().execute(processInput)); + runBusinessStep(processInput)); } diff --git a/src/test/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcessTest.java deleted file mode 100644 index e678823..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcessTest.java +++ /dev/null @@ -1,220 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for SendEmailProcess. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.email.processes; - - -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qbits.crm.email.model.EmailMessage; -import com.kingsrook.qbits.crm.email.model.EmailTemplate; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - - -/******************************************************************************* - ** Tests for sending emails: activity + message creation, doNotEmail rejection, - ** and template-based sends. - *******************************************************************************/ -class SendEmailProcessTest extends BaseTest -{ - - /******************************************************************************* - ** Send an email and verify activity + email message records created. - *******************************************************************************/ - @Test - void testSendEmail() throws QException - { - /////////////////////////////////////// - // set up prerequisites // - /////////////////////////////////////// - insertActivityType("Email"); - Integer contactId = insertContact("jane@example.com", "Jane", "Doe", false); - - //////////////////////////////////// - // run the SendEmail process // - //////////////////////////////////// - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(SendEmailProcess.NAME); - processInput.addValue("contactId", contactId); - processInput.addValue("subject", "Test Subject"); - processInput.addValue("bodyHtml", "

Hello

"); - processInput.addValue("bodyText", "Hello"); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - //////////////////////////////////////////// - // verify the activity record was created // - //////////////////////////////////////////// - QueryOutput activityQuery = new QueryAction().execute( - new QueryInput(Activity.TABLE_NAME)); - assertEquals(1, activityQuery.getRecords().size()); - - Activity activity = new Activity(activityQuery.getRecords().get(0)); - assertEquals("Test Subject", activity.getSubject()); - assertEquals(contactId, activity.getContactId()); - assertTrue(activity.getIsCompleted()); - - //////////////////////////////////////////////// - // verify the email message record was created // - //////////////////////////////////////////////// - QueryOutput emailQuery = new QueryAction().execute( - new QueryInput(EmailMessage.TABLE_NAME)); - assertEquals(1, emailQuery.getRecords().size()); - - EmailMessage emailMsg = new EmailMessage(emailQuery.getRecords().get(0)); - assertEquals("Test Subject", emailMsg.getSubject()); - assertEquals(contactId, emailMsg.getContactId()); - assertEquals("

Hello

", emailMsg.getBodyHtml()); - assertEquals("jane@example.com", emailMsg.getToAddresses()); - assertNotNull(emailMsg.getSentDate()); - assertTrue(emailMsg.getIsTracked()); - assertEquals(0, emailMsg.getOpenCount()); - - assertNotNull(processOutput.getValues().get("emailMessageId")); - } - - - - /******************************************************************************* - ** Verify that sending to a doNotEmail contact throws QUserFacingException. - *******************************************************************************/ - @Test - void testSendEmailDoNotEmail() - { - assertThrows(Exception.class, () -> - { - insertActivityType("Email"); - Integer contactId = insertContact("blocked@example.com", "Blocked", "User", true); - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(SendEmailProcess.NAME); - processInput.addValue("contactId", contactId); - processInput.addValue("subject", "Test"); - processInput.addValue("bodyHtml", "

Test

"); - - new RunProcessAction().execute(processInput); - }); - } - - - - /******************************************************************************* - ** Send email with template and verify template fields are used. - *******************************************************************************/ - @Test - void testSendEmailWithTemplate() throws QException - { - insertActivityType("Email"); - Integer contactId = insertContact("temp@example.com", "Temp", "User", false); - Integer templateId = insertEmailTemplate("Welcome", "Welcome Subject", "

Welcome

", "Welcome text"); - - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(SendEmailProcess.NAME); - processInput.addValue("contactId", contactId); - processInput.addValue("emailTemplateId", templateId); - - RunProcessOutput processOutput = new RunProcessAction().execute(processInput); - - QueryOutput emailQuery = new QueryAction().execute( - new QueryInput(EmailMessage.TABLE_NAME)); - assertEquals(1, emailQuery.getRecords().size()); - - EmailMessage emailMsg = new EmailMessage(emailQuery.getRecords().get(0)); - assertEquals("Welcome Subject", emailMsg.getSubject()); - assertEquals("

Welcome

", emailMsg.getBodyHtml()); - } - - - - /*************************************************************************** - ** Helper: insert an ActivityType and return its id. - ***************************************************************************/ - private void insertActivityType(String name) throws QException - { - new InsertAction().execute( - new InsertInput(ActivityType.TABLE_NAME) - .withRecordEntity(new ActivityType() - .withName(name) - .withIconName("email") - .withIsSystem(false) - .withSortOrder(1) - .withIsActive(true))); - } - - - - /*************************************************************************** - ** Helper: insert a Contact and return its id. - ***************************************************************************/ - private Integer insertContact(String email, String firstName, String lastName, boolean doNotEmail) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecordEntity( - new Contact() - .withEmail(email) - .withFirstName(firstName) - .withLastName(lastName) - .withDoNotEmail(doNotEmail) - .withOwnerUserId("user-001"))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert an EmailTemplate and return its id. - ***************************************************************************/ - private Integer insertEmailTemplate(String name, String subject, String bodyHtml, String bodyText) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(EmailTemplate.TABLE_NAME).withRecordEntity( - new EmailTemplate() - .withName(name) - .withSubject(subject) - .withBodyHtml(bodyHtml) - .withBodyText(bodyText) - .withOwnerUserId("user-001") - .withIsShared(false))); - return (output.getRecords().get(0).getValueInteger("id")); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcessTest.java deleted file mode 100644 index faa99ae..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcessTest.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for TrackEmailEventProcess. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.email.processes; - - -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType; -import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; -import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus; -import com.kingsrook.qbits.crm.email.model.EmailMessage; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; -import com.kingsrook.qqq.backend.core.actions.tables.GetAction; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - - -/******************************************************************************* - ** Tests for email event tracking: open/click increment, hard bounce and - ** spam set doNotEmail on the contact. - *******************************************************************************/ -class TrackEmailEventProcessTest extends BaseTest -{ - - /******************************************************************************* - ** Test OPEN event increments openCount and sets firstOpenedDate. - *******************************************************************************/ - @Test - void testOpenEvent() throws QException - { - Integer contactId = insertContact("open@example.com"); - Integer emailId = insertEmailMessage(contactId); - - runTrackEvent(emailId, "OPEN", null); - - EmailMessage updated = new EmailMessage(GetAction.execute(EmailMessage.TABLE_NAME, emailId)); - assertEquals(1, updated.getOpenCount()); - assertNotNull(updated.getFirstOpenedDate()); - - /////////////////////////////////// - // second open should increment // - /////////////////////////////////// - runTrackEvent(emailId, "OPEN", null); - updated = new EmailMessage(GetAction.execute(EmailMessage.TABLE_NAME, emailId)); - assertEquals(2, updated.getOpenCount()); - } - - - - /******************************************************************************* - ** Test CLICK event increments clickCount and sets firstClickedDate. - *******************************************************************************/ - @Test - void testClickEvent() throws QException - { - Integer contactId = insertContact("click@example.com"); - Integer emailId = insertEmailMessage(contactId); - - runTrackEvent(emailId, "CLICK", null); - - EmailMessage updated = new EmailMessage(GetAction.execute(EmailMessage.TABLE_NAME, emailId)); - assertEquals(1, updated.getClickCount()); - assertNotNull(updated.getFirstClickedDate()); - } - - - - /******************************************************************************* - ** Test BOUNCE HARD sets bounceType and contact doNotEmail. - *******************************************************************************/ - @Test - void testHardBounceEvent() throws QException - { - Integer contactId = insertContact("bounce@example.com"); - Integer emailId = insertEmailMessage(contactId); - - runTrackEvent(emailId, "BOUNCE", CrmBounceType.HARD.getId()); - - EmailMessage updated = new EmailMessage(GetAction.execute(EmailMessage.TABLE_NAME, emailId)); - assertEquals(CrmBounceType.HARD.getId(), updated.getBounceType()); - - Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); - assertTrue(contact.getDoNotEmail()); - } - - - - /******************************************************************************* - ** Test SPAM event sets isSpamComplaint and contact doNotEmail. - *******************************************************************************/ - @Test - void testSpamEvent() throws QException - { - Integer contactId = insertContact("spam@example.com"); - Integer emailId = insertEmailMessage(contactId); - - runTrackEvent(emailId, "SPAM", null); - - EmailMessage updated = new EmailMessage(GetAction.execute(EmailMessage.TABLE_NAME, emailId)); - assertTrue(updated.getIsSpamComplaint()); - - Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); - assertTrue(contact.getDoNotEmail()); - } - - - - /*************************************************************************** - ** Helper: run the track event process. - ***************************************************************************/ - private void runTrackEvent(Integer emailId, String eventType, Integer bounceTypeId) throws QException - { - RunProcessInput processInput = new RunProcessInput(); - processInput.setProcessName(TrackEmailEventProcess.NAME); - processInput.addValue("emailMessageId", emailId); - processInput.addValue("eventType", eventType); - if(bounceTypeId != null) - { - processInput.addValue("bounceTypeId", bounceTypeId); - } - new RunProcessAction().execute(processInput); - } - - - - /*************************************************************************** - ** Helper: insert a Contact and return its id. - ***************************************************************************/ - private Integer insertContact(String email) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecordEntity( - new Contact() - .withEmail(email) - .withFirstName("Test") - .withLastName("User") - .withDoNotEmail(false) - .withOwnerUserId("user-001"))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert an EmailMessage and return its id. - ***************************************************************************/ - private Integer insertEmailMessage(Integer contactId) throws QException - { - Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); - - InsertOutput output = new InsertAction().execute( - new InsertInput(EmailMessage.TABLE_NAME).withRecordEntity( - new EmailMessage() - .withFromAddress("sender@example.com") - .withToAddresses(contact.getEmail()) - .withSubject("Test Email") - .withDirection(CrmDirection.OUTBOUND.getId()) - .withStatus(CrmEmailStatus.SENT.getId()) - .withIsTracked(true) - .withOpenCount(0) - .withClickCount(0) - .withIsSpamComplaint(false))); - return (output.getRecords().get(0).getValueInteger("id")); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessCoverageTest.java b/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessCoverageTest.java index 0de319b..e1e5b07 100644 --- a/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessCoverageTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessCoverageTest.java @@ -38,11 +38,10 @@ import com.kingsrook.qbits.crm.deals.model.Pipeline; import com.kingsrook.qbits.crm.deals.model.PipelineStage; import com.kingsrook.qbits.crm.scoring.model.AssignmentRule; import com.kingsrook.qbits.crm.scoring.model.AssignmentRuleMember; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.actions.tables.insert.InsertOutput; import org.junit.jupiter.api.Test; @@ -92,7 +91,7 @@ class AutoAssignProcessCoverageTest extends BaseTest Integer contactId = insertContact("Alice", "Test"); - RunProcessOutput output = runAutoAssign("CONTACT", contactId); + RunBackendStepOutput output = runAutoAssign("CONTACT", contactId); assertEquals("ASSIGNED", output.getValues().get("action")); // user-B has null lastAssignedDate => treated as EPOCH => least active assertEquals("user-B", output.getValues().get("assignedUserId")); @@ -119,7 +118,7 @@ class AutoAssignProcessCoverageTest extends BaseTest Integer contactId = insertContactWithState("Bob", "Test", "CO"); - RunProcessOutput output = runAutoAssign("CONTACT", contactId); + RunBackendStepOutput output = runAutoAssign("CONTACT", contactId); assertEquals("ASSIGNED", output.getValues().get("action")); assertEquals("co-rep", output.getValues().get("assignedUserId")); } @@ -145,7 +144,7 @@ class AutoAssignProcessCoverageTest extends BaseTest Integer contactId = insertContactWithState("Carol", "Test", "CO"); - RunProcessOutput output = runAutoAssign("CONTACT", contactId); + RunBackendStepOutput output = runAutoAssign("CONTACT", contactId); assertEquals("NO_MATCH", output.getValues().get("action")); } @@ -159,7 +158,7 @@ class AutoAssignProcessCoverageTest extends BaseTest { Integer contactId = insertContact("Eve", "Invalid"); - RunProcessOutput output = runAutoAssign("NONEXISTENT", contactId); + RunBackendStepOutput output = runAutoAssign("NONEXISTENT", contactId); assertEquals("NO_MATCH", output.getValues().get("action")); } @@ -181,7 +180,7 @@ class AutoAssignProcessCoverageTest extends BaseTest .withIsActive(true) .withSortOrder(1))); - RunProcessOutput output = runAutoAssign("CONTACT", 99999); + RunBackendStepOutput output = runAutoAssign("CONTACT", 99999); assertEquals("RECORD_NOT_FOUND", output.getValues().get("action")); } @@ -205,7 +204,7 @@ class AutoAssignProcessCoverageTest extends BaseTest Integer dealId = insertDeal("Test Deal"); - RunProcessOutput output = runAutoAssign("DEAL", dealId); + RunBackendStepOutput output = runAutoAssign("DEAL", dealId); assertEquals("ASSIGNED", output.getValues().get("action")); assertEquals("deal-owner", output.getValues().get("assignedUserId")); } @@ -229,7 +228,7 @@ class AutoAssignProcessCoverageTest extends BaseTest Integer contactId = insertContact("Lonely", "User"); - RunProcessOutput output = runAutoAssign("CONTACT", contactId); + RunBackendStepOutput output = runAutoAssign("CONTACT", contactId); assertEquals("NO_MATCH", output.getValues().get("action")); } @@ -253,7 +252,7 @@ class AutoAssignProcessCoverageTest extends BaseTest Integer contactId = insertContact("Label", "Test"); - RunProcessOutput output = runAutoAssign(CrmAssignableEntity.CONTACT.getLabel(), contactId); + RunBackendStepOutput output = runAutoAssign(CrmAssignableEntity.CONTACT.getLabel(), contactId); assertEquals("ASSIGNED", output.getValues().get("action")); } @@ -329,13 +328,13 @@ class AutoAssignProcessCoverageTest extends BaseTest /******************************************************************************* ** Run Auto Assign. *******************************************************************************/ - private RunProcessOutput runAutoAssign(String entityType, Integer recordId) throws QException + private RunBackendStepOutput runAutoAssign(String entityType, Integer recordId) throws QException { - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(AutoAssignProcess.NAME); processInput.addValue("entityType", entityType); processInput.addValue("recordId", recordId); - return (new RunProcessAction().execute(processInput)); + return (runBusinessStep(processInput)); } } diff --git a/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessTest.java index 5e1c411..230d3cd 100644 --- a/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcessTest.java @@ -32,12 +32,11 @@ import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy; import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity; import com.kingsrook.qbits.crm.scoring.model.AssignmentRule; import com.kingsrook.qbits.crm.scoring.model.AssignmentRuleMember; -import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +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.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; @@ -80,7 +79,7 @@ class AutoAssignProcessTest extends BaseTest ///////////////////////////////////////////// // run auto-assign // ///////////////////////////////////////////// - RunProcessOutput processOutput = runAutoAssign("CONTACT", contactId); + RunBackendStepOutput processOutput = runAutoAssign("CONTACT", contactId); assertEquals("ASSIGNED", processOutput.getValues().get("action")); assertEquals("sales-lead-001", processOutput.getValues().get("assignedUserId")); @@ -136,7 +135,7 @@ class AutoAssignProcessTest extends BaseTest // first contact should go to user-A // ///////////////////////////////////////////// Integer contactId1 = insertContact("Bob", "First"); - RunProcessOutput output1 = runAutoAssign("CONTACT", contactId1); + RunBackendStepOutput output1 = runAutoAssign("CONTACT", contactId1); assertEquals("ASSIGNED", output1.getValues().get("action")); assertEquals("user-A", output1.getValues().get("assignedUserId")); @@ -144,7 +143,7 @@ class AutoAssignProcessTest extends BaseTest // second contact should go to user-B // ///////////////////////////////////////////// Integer contactId2 = insertContact("Carol", "Second"); - RunProcessOutput output2 = runAutoAssign("CONTACT", contactId2); + RunBackendStepOutput output2 = runAutoAssign("CONTACT", contactId2); assertEquals("ASSIGNED", output2.getValues().get("action")); assertEquals("user-B", output2.getValues().get("assignedUserId")); } @@ -159,7 +158,7 @@ class AutoAssignProcessTest extends BaseTest { Integer contactId = insertContact("Eve", "NoRule"); - RunProcessOutput processOutput = runAutoAssign("CONTACT", contactId); + RunBackendStepOutput processOutput = runAutoAssign("CONTACT", contactId); assertEquals("NO_MATCH", processOutput.getValues().get("action")); @@ -194,13 +193,13 @@ class AutoAssignProcessTest extends BaseTest /*************************************************************************** ** Helper: run the auto-assign process. ***************************************************************************/ - private RunProcessOutput runAutoAssign(String entityType, Integer recordId) throws QException + private RunBackendStepOutput runAutoAssign(String entityType, Integer recordId) throws QException { - RunProcessInput processInput = new RunProcessInput(); + RunBackendStepInput processInput = new RunBackendStepInput(); processInput.setProcessName(AutoAssignProcess.NAME); processInput.addValue("entityType", entityType); processInput.addValue("recordId", recordId); - return (new RunProcessAction().execute(processInput)); + return (runBusinessStep(processInput)); } } -- 2.54.0