Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 52c020d3f3 feat: contingent possible values for deal stage, activity outcome and list-member submission
Deal pipelineStageId follows pipelineId, Activity outcomeId follows activityTypeId, EmailListMember formSubmissionId follows contactId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:13:41 -05:00
james.maes 8dad04943f Merge pull request #5 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 15:00:35 -05:00
james.maesandClaude Opus 5.5 569e1e345c ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:37:34 -05:00
james.maes 6c61c35782 Merge pull request #4 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:08:13 -05:00
james.maesandClaude Opus 5.5 5ecfe1bd56 ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:01:25 -05:00
James MaesandClaude Opus 4.8 650e38f5a0 docs: add CLAUDE.md pointer to QQQ knowledge-base dossier + hub
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:31:01 -05:00
james.maesandClaude Opus 4.7 76b95c91ee chore: add CODEOWNERS pointing to sole maintainer
Architecture audit pass 2026-05-21. Minimal CODEOWNERS file
making the implicit ownership explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:14:01 -05:00
CircleCI 88a25c5a72 Bump version to 0.1.0 [skip ci] 2026-03-29 21:08:11 +00:00
james.maes e7d6050652 Merge remote-tracking branch 'origin/main' into develop 2026-03-29 16:06:33 -05:00
james.maes 2a68ab4d14 chore: bump develop to 0.2.0-SNAPSHOT 2026-03-29 15:55:40 -05:00
8 changed files with 192 additions and 12 deletions

No files matched your search

+2 -12
View File
@@ -11,7 +11,7 @@ workflows:
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/
ignore: /(develop|main|release/.*|integration.*)/
tags:
only: []
@@ -44,14 +44,4 @@ workflows:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
context: [ qqq-maven-registry-credentials ]
branch_type: hotfix
filters:
branches:
only:
- /hotfix\/.*/
only: [ /^v\d+\.\d+\.\d+$/ ]
+1
View File
@@ -0,0 +1 @@
* @KofTwentyTwo
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+15
View File
@@ -0,0 +1,15 @@
# qbit-crm
## Knowledge base
Reviewed dossiers for this repo and the QQQ platform live in the second-brain vault:
- Hub / start here: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-crm.md`
(reviewed commit `76b95c91ee7e`, branch `develop`, 2026-07-04)
- QBit mechanics refresher: `knowledge/qqq/architecture/metadata-model.md` in the same vault
Key facts captured there: parent pom `qbit-build-parent:1.4.0` pins qqq to 0.27.9 (via
`qqq-bom-pom` import); two BREAK-04 compile breaks block a qqq 4.0 upgrade
(`BaseTest.setAuthentication`, `EnrollInSequenceProcess input.getSession()`); Maven
Central's published `0.1.0` predates the AGPL→Apache license switch and the audit fixes.
@@ -13,6 +13,9 @@ import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.email.model.EmailMessage;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
@@ -87,6 +90,12 @@ public class Activity extends QRecordEntity
.withSection(SectionFactory.customT2("participants", new QIcon("group")).withWidgetName(participantChildJoinName))
.withSection(SectionFactory.defaultT3("externalCalendarId", "createDate", "modifyDate"));
//////////////////////////////////////////////////////////////////
// Only offer outcomes belonging to the selected activity type. //
//////////////////////////////////////////////////////////////////
table.getField("outcomeId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("activityTypeId", QCriteriaOperator.EQUALS, "${input.activityTypeId}")));
return (table);
}
}
@@ -7,6 +7,9 @@ package com.kingsrook.qbits.crm.core.model;
import java.time.Instant;
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
@@ -55,6 +58,12 @@ public class EmailListMember extends QRecordEntity
.withSection(SectionFactory.defaultT2("subscribedDate", "unsubscribedDate", "subscriptionSource", "formSubmissionId"))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
////////////////////////////////////////////////////////////
// Only offer submissions linked to the selected contact. //
////////////////////////////////////////////////////////////
table.getField("formSubmissionId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, "${input.contactId}")));
return (table);
}
}
@@ -14,6 +14,9 @@ import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.LeadSource;
import com.kingsrook.qbits.crm.core.model.enums.CrmDealPriority;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
@@ -129,6 +132,12 @@ public class Deal extends QRecordEntity
}
}
///////////////////////////////////////////////////////////
// Only offer stages belonging to the selected pipeline. //
///////////////////////////////////////////////////////////
table.getField("pipelineStageId").withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}")));
return (table);
}
}
@@ -0,0 +1,122 @@
/*******************************************************************************
** Tests for CRM choices scoped by another editable field on the same form.
*******************************************************************************/
package com.kingsrook.qbits.crm;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.activities.model.ActivityOutcome;
import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess;
import com.kingsrook.qbits.crm.core.model.EmailListMember;
import com.kingsrook.qbits.crm.core.model.FormSubmission;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.values.SearchPossibleValueSourceInput;
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;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Exercise the generated metadata and real possible-value searches in memory.
*******************************************************************************/
class ContingentPossibleValuesTest extends BaseTest
{
/*******************************************************************************
** Every contingent field must point to an existing scope column and retain
** its template after searches for either scope and either empty-input mode.
*******************************************************************************/
@ParameterizedTest
@CsvSource({
Deal.TABLE_NAME + ", pipelineId, pipelineStageId, " + PipelineStage.TABLE_NAME,
Activity.TABLE_NAME + ", activityTypeId, outcomeId, " + ActivityOutcome.TABLE_NAME,
EmailListMember.TABLE_NAME + ", contactId, formSubmissionId, " + FormSubmission.TABLE_NAME
})
void testContingentSearch(String tableName, String parent, String child, String sourceTable) throws QException
{
QTableMetaData table = QContext.getQInstance().getTable(tableName);
QFieldMetaData field = table.getField(child);
assertThat(table.getField(parent).getIsEditable()).isTrue();
assertThat(field.getIsEditable()).isTrue();
assertThat(QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName()).getTableName()).isEqualTo(sourceTable);
assertThat(QContext.getQInstance().getTable(sourceTable).getField(parent)).isNotNull();
QQueryFilter template = field.getPossibleValueSourceFilter();
assertThat(template).isNotNull();
QFilterCriteria expected = new QFilterCriteria(parent, QCriteriaOperator.EQUALS, "${input." + parent + "}");
assertThat(template.getCriteria()).containsExactly(expected);
//////////////////////////////////////////////////////////////////////////////////
// Seed the search backend directly so insert customizers cannot relink scopes. //
//////////////////////////////////////////////////////////////////////////////////
MemoryRecordStore.getInstance().insert(new InsertInput(sourceTable).withRecords(List.of(
scopedRecord(11, parent, 1), scopedRecord(12, parent, 1), scopedRecord(21, parent, 2))), false);
assertThat(search(field, Map.of(parent, 1), PossibleValueSearchFilterUseCase.FORM)).containsExactlyInAnyOrder(11, 12);
assertThat(search(field, Map.of(parent, 2), PossibleValueSearchFilterUseCase.FORM)).containsExactly(21);
assertThat(search(field, Map.of(), PossibleValueSearchFilterUseCase.FORM)).isEmpty();
assertThat(search(field, Map.of(), PossibleValueSearchFilterUseCase.FILTER)).containsExactlyInAnyOrder(11, 12, 21);
assertThat(template.getCriteria()).containsExactly(expected);
}
/*******************************************************************************
** Optional company links and backend-only process inputs stay unrestricted.
*******************************************************************************/
@Test
void testSkippedFields()
{
QTableMetaData activity = QContext.getQInstance().getTable(Activity.TABLE_NAME);
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);
}
/*******************************************************************************
** Build a labeled row usable by each of the three table possible-value sources.
*******************************************************************************/
private QRecord scopedRecord(int id, String parent, int scope)
{
return new QRecord().withValue("id", id).withValue(parent, scope)
.withValue("name", "Choice " + id).withValue("firstName", "Contact " + id)
.withValue("email", "contact" + id + "@example.com").withValue("formType", 1);
}
/*******************************************************************************
** Interpret a cloned field filter just as a frontend form or filter does.
*******************************************************************************/
private List<Object> search(QFieldMetaData field, Map<String, Serializable> values, PossibleValueSearchFilterUseCase useCase) throws QException
{
QQueryFilter filter = field.getPossibleValueSourceFilter().clone();
filter.interpretValues(MapBuilder.of("input", values), useCase);
return new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(field.getPossibleValueSourceName())
.withDefaultQueryFilter(filter)).getResults().stream().<Object>map(QPossibleValue::getId).toList();
}
}