diff --git a/.circleci/config.yml b/.circleci/config.yml
index 0dcd5f9..aec59b2 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,47 +1,60 @@
version: 2.1
orbs:
- qqq-orb: kingsrook/qqq-orb@0.3.8
+ qqq-orb: kingsrook/qqq-orb@0.6.9
+
+commands:
+ configure-package-install:
+ steps:
+ - run:
+ name: Keep package installation unattended
+ command: |
+ # These build machines are discarded after the job.
+ sudo install -d /etc/needrestart/conf.d
+ printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
workflows:
+ test_only:
+ jobs:
+ - qqq-orb/mvn_test_only:
+ pre-steps:
+ - configure-package-install
+ context: [qqq-maven-registry-credentials]
+ filters:
+ branches:
+ ignore: /(develop|release\/.*)/
- test_only:
- jobs:
- - qqq-orb/mvn_test_only:
- context: [ qqq-maven-registry-credentials ]
- filters:
- branches:
- ignore: /(develop|main|release/.*|integration.*)/
- tags:
- only: []
+ publish_snapshot:
+ jobs:
+ - qqq-orb/mvn_publish:
+ pre-steps:
+ - configure-package-install
+ context: [qqq-maven-registry-credentials]
+ branch_type: snapshot
+ filters:
+ branches:
+ only: [develop]
- publish_snapshot:
- jobs:
- - qqq-orb/mvn_publish:
- context: [ qqq-maven-registry-credentials ]
- branch_type: snapshot
- filters:
- branches:
- only:
- - develop
+ publish_release_candidate:
+ jobs:
+ - qqq-orb/mvn_publish:
+ pre-steps:
+ - configure-package-install
+ context: [qqq-maven-registry-credentials]
+ branch_type: release_candidate
+ filters:
+ branches:
+ only: [/release\/.*/]
- publish_release_candidate:
- jobs:
- - qqq-orb/mvn_publish:
- context: [ qqq-maven-registry-credentials ]
- branch_type: release_candidate
- filters:
- branches:
- only:
- - /release\/.*/
-
- publish_release:
- jobs:
- - qqq-orb/mvn_publish:
- context: [ qqq-maven-registry-credentials ]
- branch_type: release
- filters:
- branches:
- only: [ main ]
- tags:
- only: [ /^v\d+\.\d+\.\d+$/ ]
+ publish_release:
+ jobs:
+ - qqq-orb/mvn_publish:
+ pre-steps:
+ - configure-package-install
+ context: [qqq-maven-registry-credentials]
+ branch_type: release
+ filters:
+ branches:
+ ignore: /.*/
+ tags:
+ only: [/^v\d+\.\d+\.\d+$/]
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dc513d7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+mvnw text eol=lf
+mvnw.cmd text eol=crlf
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..c0bcafe
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5286020
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,15 @@
+# QBit CRM
+
+CRM metadata, processes and widgets for QQQ. P1 upgrades the platform; later phases are defined in the 0.2.0 design.
+
+## Build
+
+Use Java 21 and the Maven wrapper: `./mvnw -q verify` runs checkstyle, tests and JaCoCo (70% instructions, 90% classes). Parent: `qbit-build-parent:2.1.2`; default-active `qqq-snapshot` BOM: `4.1.0-RC.6`. Architecture guardrails and strict UI API routes stay enabled.
+
+## Conventions
+
+- Entry point: `CrmQBitProducer`, with package-discovered metadata producers.
+- Table, join, widget and process names use `crm_` snake_case. Database columns use snake_case; Java fields stay camelCase.
+- Allman braces, 3-space indentation, `return (x);`, fluent setters and Javadoc flower-box banners.
+- New source files carry the Apache 2.0 license header.
+- Keep changes scoped and run verify before committing. Do not push without authorization.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..907444b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,12 @@
+# Changelog
+
+## Unreleased / 0.2.0
+
+- Upgrade to Java 21, QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.2.
+- Add Maven wrapper, CI configuration, architecture guardrails and coverage gates.
+- Rename tables, joins, widgets and CRM processes to `crm_` snake_case, with inferred snake_case SQL columns.
+- Remove bespoke tag, attachment and audit tables, bulk tag processes and unused namespace/prefix configuration.
+- Use core FIELD-level auditing on contacts, companies, deals and activities.
+- Apply CRM, management and admin permission rules and source-QBit metadata, including an RC.6 widget provenance adapter.
+- Ship PostgreSQL DDL, 33 Liquibase changesets, and H2 persistence and schema tests. 0.2.0 is a fresh install; there is no upgrade path from 0.1.0.
+- Correct installation instructions and document the existing P1 behavior and remaining limitations.
diff --git a/README.md b/README.md
index fb5830f..ab675ff 100644
--- a/README.md
+++ b/README.md
@@ -1,485 +1,140 @@
-# QBit: CRM
+# QBit CRM
-[](https://github.com/QRun-IO/qbit-crm)
-[](https://www.apache.org/licenses/LICENSE-2.0)
-[](https://adoptium.net/)
+**0.2.0-SNAPSHOT — unreleased, P1 Platform.** CRM tables, processes and widgets for QQQ applications. This phase upgrades the build, names, permissions, audit integration and database schema. It does not complete the later 0.2.0 product phases.
-> **Customer Relationship Management for QQQ Applications - Activity-Centric, Pipeline-Driven**
+## Build and verification
-This QBit provides a complete CRM system for QQQ applications, covering contact and company management, deal pipeline tracking, activity logging, email tracking, product catalog, lead scoring, assignment automation, website form capture, mailing list subscriptions, and reporting.
+Use **Java 21** and the included Maven wrapper (**Maven 3.9.11**). The build uses `com.kingsrook:qbit-build-parent:2.1.2` and a default-active `qqq-snapshot` profile importing `qqq-bom-pom:4.1.0-RC.6`.
-## Core Capabilities
-
-- **Contact & Company Management**: Full lifecycle tracking with tagging, segmentation, and duplicate detection
-- **Deal Pipeline**: Configurable multi-pipeline stages with probability weighting, rot/aging detection, and stage-gating rules
-- **Activity Tracking**: Unified activity table for calls, emails, meetings, notes, and tasks with per-record timelines
-- **Email Integration**: Tracked email sending, open/click/bounce detection, templates with merge fields
-- **Email Sequences**: Multi-step automated outreach with configurable send windows and failure handling
-- **Lead Scoring**: Rule-based scoring with dot-notation field paths and time-decay operators
-- **Assignment Automation**: Round-robin, least-active, and rule-based auto-assignment
-- **Website Forms**: Capture web form submissions with UTM tracking and lead conversion
-- **Mailing Lists**: Newsletter and subscription management independent of sales sequences
-- **GDPR Compliance**: Consent tracking, right-to-erasure via anonymization (not deletion)
-- **Multi-Currency**: Exchange rate table with converted amounts for accurate pipeline reporting
-- **Immutable Audit Log**: Field-level change tracking with anonymization support for GDPR
-- **14 Dashboard Widgets**: Pipeline summary, forecast, leaderboard, conversion rates, win rate, activity feed, and more
-
-## Open Source & Full Control
-
-QBit CRM is 100% open source under Apache 2.0. All data stays in your systems. No external CRM services required.
-
-## Architecture
-
-### Design Principles
-
-1. **Activity-centric**: A unified `crm_activity` table is the heart of the system. Every interaction (calls, emails, meetings, notes, tasks) is an activity linked to contacts, companies, and deals. One table to query, one timeline per record.
-
-2. **Pipeline-driven**: Deals move through configurable pipeline stages. Each stage has probability weighting, rot thresholds, and optional required-field gating. Stage transitions are first-class events captured in `crm_deal_stage_history` for reliable analytics.
-
-3. **Perpetual activity log**: The `crm_audit_log` table is immutable and append-only. Every meaningful change is recorded with field-level old/new values. For GDPR, PII is anonymized in-place rather than deleted.
-
-### Technology Stack
-
-- **Java 17+** with QQQ backend modules
-- **QQQ Framework**: Entities, processes, widgets, permissions, API layer
-- **Database**: RDBMS through QQQ's backend abstraction
-- **MemoryRecordStore**: Full test suite runs in-memory (no database required)
-
-### Module Organization
-
-```
-qbit-crm/
- src/main/java/com/kingsrook/qbits/crm/
- CrmQBitConfig.java -- Configuration (backend, security, companion QBits)
- CrmQBitProducer.java -- Gen 2 QBitMetaDataProducer (auto-discovers all entities)
- core/ -- Contacts, Companies, Tags, Attachments, Forms, Email Lists
- deals/ -- Deals, Pipelines, Stages, Products, Line Items, Stage History
- activities/ -- Activities, Activity Types, Outcomes, Participants
- email/ -- Email Messages, Templates, Sequences, Enrollments
- scoring/ -- Lead Score Rules, Assignment Rules, Sales Goals
- audit/ -- Immutable audit log + CrmAuditLogCustomizer
- sync/ -- External system mapping, Currency management
- widgets/ -- 14 dashboard widgets (producers + renderers)
+```sh
+./mvnw -q verify
+# Windows PowerShell:
+./mvnw.cmd -q verify
+# If dependencies are already cached and network resolution is unavailable:
+./mvnw.cmd -o -q verify
```
-## Getting Started
+Verification runs Checkstyle on production and test sources, architecture guardrails (including strict UI API routes), JUnit tests and JaCoCo 0.8.15. Coverage gates are 70% instructions and 90% classes. The current suite runs **372 tests**, with no failures or skips, including four H2 integration tests. Tests use H2 2.3.232 and AssertJ 3.27.7. CircleCI uses `kingsrook/qqq-orb@0.6.9`.
-### Prerequisites
+The H2 tests execute the shipped PostgreSQL DDL in PostgreSQL mode. They verify all 33 tables and 395 columns, the 21 declared unique keys, relationship indexes, seven persisted entity types, duplicate list-member rejection and DDL/changelog parity. They do not replace deployment verification against the host's PostgreSQL installation.
-- **Java 17+**
-- **Maven 3.8+**
-- **A QQQ Application** with an RDBMS backend (PostgreSQL, MySQL, H2, etc.)
-- **QQQ Maven Registry** access (for resolving the dependency)
+## Install in a host application
-### Step 1: Add the Maven dependency
+1. Build/install this unreleased snapshot with `./mvnw install`, or resolve the same artifact from your configured snapshot repository. The dependency version is explicit; do not assume the QQQ BOM manages this QBit.
```xml
- com.kingsrook.qbits
- qbit-crm
- 0.1.0-SNAPSHOT
+ com.kingsrook.qbits
+ qbit-crm
+ 0.2.0-SNAPSHOT
```
-If you are using the QQQ BOM POM, the version is managed for you:
-```xml
-
-
-
- com.kingsrook.qqq
- qqq-bom-pom
- ${qqq.version}
- pom
- import
-
-
-
-```
+2. Align the host with QQQ **4.1.0-RC.6**. For a new database, apply [the reference PostgreSQL DDL](docs/ddl/postgres.sql), or include the packaged Liquibase formatted SQL at `db/liquibase/qbit-crm/crm.changelog.sql`. Choose one installation path. The Java producer does not create the schema.
-### Step 2: Configure and produce the QBit
-
-In your application's metadata setup class (where you build your `QInstance`), add the CRM QBit. The `defaultBackendNameForTables` must match a backend you have already registered with your QInstance (typically your RDBMS backend).
+3. Register the host backend and authentication, then produce and add the CRM metadata:
```java
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.CrmQBitProducer;
-import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
-
-// In your QInstance setup method:
-public void defineQInstance(QInstance qInstance)
-{
- // ... your existing backend, auth, and app setup ...
-
- ///////////////////////////////
- // configure and produce CRM //
- ///////////////////////////////
- CrmQBitConfig crmConfig = new CrmQBitConfig()
- .withDefaultBackendNameForTables("rdbms"); // must match a registered backend name
-
- CrmQBitProducer crmProducer = new CrmQBitProducer()
- .withQBitConfig(crmConfig);
-
- MetaDataProducerMultiOutput crmOutput = crmProducer.produce(qInstance);
- crmOutput.addSelfToInstance(qInstance);
-}
-```
-
-### Step 3: Create the database tables
-
-The QBit defines 39 tables. On first run against a new database, you need to create the schema. QQQ can auto-create tables if your backend supports it, or you can generate DDL from the entity metadata.
-
-For **development/testing** with H2 or auto-DDL:
-```java
-// QQQ will auto-create tables in memory backends.
-// For RDBMS with auto-DDL enabled, tables are created on startup.
-```
-
-For **production** with PostgreSQL/MySQL, generate migration scripts from the entity field definitions. Each entity class documents its table name (e.g., `crmContact`, `crmCompany`, `crmDeal`) and all field types. Column names in the database are `snake_case` versions of the Java `camelCase` field names (e.g., `firstName` -> `first_name`).
-
-### Step 4: Add CRM to your app navigation
-
-```java
-import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
-
-QAppMetaData app = new QAppMetaData()
- .withName("myApp")
- .withLabel("My Application")
- .withSections(List.of(
- CrmQBitProducer.produceAppSection(),
- // ... your other app sections ...
- ));
-qInstance.addApp(app);
-```
-
-This adds a "CRM" navigation section to the QQQ Material Dashboard with links to all CRM tables.
-
-### Step 5 (optional): Seed reference data
-
-The CRM works without seed data, but you'll want to populate the configuration tables before users start working:
-
-- **`crmLifecycleStage`**: Lead, Marketing Qualified, Sales Qualified, Opportunity, Customer, Evangelist
-- **`crmLeadSource`**: Website, Referral, Cold Call, Trade Show, Social Media, Advertisement, Partner
-- **`crmIndustry`**: Technology, Healthcare, Finance, Manufacturing, Retail, etc.
-- **`crmActivityType`**: Call, Email, Meeting, Note, Task (with `isSystem=true`)
-- **`crmActivityOutcome`**: Connected, Left Voicemail, No Answer (for Call type); Completed, No Show, Rescheduled (for Meeting type)
-- **`crmPipeline`** + **`crmPipelineStage`**: At minimum one pipeline with stages (Prospecting, Qualification, Proposal, Negotiation, Closed Won, Closed Lost)
-- **`crmContactRole`**: Decision Maker, Influencer, Champion, Budget Holder, End User, Evaluator
-- **`crmWinLossReason`**: Better Fit (WIN), Price (WIN), Lost to Competitor (LOSS), No Budget (LOSS), etc.
-- **`crmCurrency`**: At minimum USD with `isBaseCurrency=true` and `exchangeRateToBase=1.0`
-
-You can insert these via QQQ's `InsertAction` in an application startup process, or directly via SQL.
-
-### Step 6 (optional): Multi-tenant security
-
-For SaaS or multi-tenant deployments where each client's CRM data must be isolated:
-
-```java
-import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
+// The host has already registered the backend named "rdbms".
CrmQBitConfig crmConfig = new CrmQBitConfig()
- .withDefaultBackendNameForTables("rdbms")
- .withRecordSecurityLocks(List.of(
- new RecordSecurityLock()
- .withFieldName("clientId")
- .withSecurityKeyType("crmClientAccess")
- .withNullValueBehavior(NullValueBehavior.DENY)
- ));
+ .withDefaultBackendNameForTables("rdbms");
+
+new CrmQBitProducer()
+ .withQBitConfig(crmConfig)
+ .produce(qInstance)
+ .addSelfToInstance(qInstance);
+
+qInstance.setEnforcePermissionsInCoreActions(true);
```
-This injects a `clientId` security field onto all root CRM tables (Contact, Company, Deal, Pipeline) and propagates security locks through join chains to child tables. Users only see records matching their security key values.
+4. Map the host's authenticated roles to the permissions below. Configure the host's core audit storage. Add tables, processes and widgets to the host UI as needed: `produceAppSection()` currently returns an empty CRM section; P1 does not provide an assembled app or home dashboard.
-### Step 7 (optional): Companion QBits
+## Configuration and access
-The CRM integrates with other QBits for enhanced functionality:
+- `defaultBackendNameForTables`: required backend name.
+- `inferBackendNames`: defaults to true; sets snake_case SQL column names while Java/QQQ field names remain camelCase. SQL table names equal the QQQ table names. Set false only for an explicit host column mapping.
+- `tableMetaDataCustomizer`: optional host table customizer.
+- `securityFields` and `recordSecurityLocks`: retained in P1, applied to contact, company and deal. Host-managed schema columns must match these fields. This is not yet tenant isolation across every child table or workflow.
-```java
-CrmQBitConfig crmConfig = new CrmQBitConfig()
- .withDefaultBackendNameForTables("rdbms")
- .withQuickSearchQBitNamespace("quickSearch") // full-text search across contacts/companies/deals
- .withWebhooksQBitNamespace("webhooks") // fire events on CRM changes (deal won, contact created, etc.)
- .withWorkflowsQBitNamespace("workflows"); // user-defined automation rules
-```
+| Scope | Permissions |
+|---|---|
+| Main and sub-tables | `crm.read`, `crm.write` |
+| Settings tables except currency | `crm.manage.read`, `crm.manage.write` |
+| Currency | `crm.admin.read`, `crm.admin.write` |
+| Widgets and everyday processes | `crm.hasAccess` |
+| Merge, bulk assignment, list and sequence management processes | `crm.manage.hasAccess` |
+| GDPR anonymization | `crm.admin.hasAccess` |
-Each companion QBit must be separately added as a Maven dependency and produced into your QInstance. The namespace strings must match the namespaces you used when producing those QBits.
+Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The widget provenance adapter supplies core's `SourceQBitAware` interface because RC.6's widget class lacks it.
-### Complete example
+## Tables
-```java
-public void defineQInstance(QInstance qInstance)
-{
- ///////////////
- // backend //
- ///////////////
- qInstance.addBackend(new RDBMSBackendMetaData()
- .withName("rdbms")
- .withVendor("postgresql")
- .withHostName("localhost")
- .withDatabaseName("myapp")
- .withUsername("myapp")
- .withPassword("secret"));
+All 33 table names and table-backed possible-value sources use `crm_` snake_case. The 23 enum possible-value sources retain their enum names. Relationship columns have indexes; the reference DDL does not impose foreign-key constraints or host-specific tenant columns. Unique constraints match the current QQQ metadata.
- ////////////////////
- // authentication //
- ////////////////////
- qInstance.setAuthentication(new Auth0AuthenticationMetaData()
- .withType(QAuthenticationType.AUTH_0)
- // ... auth config ...
- );
+### Main tables (6)
- //////////
- // CRM //
- //////////
- CrmQBitConfig crmConfig = new CrmQBitConfig()
- .withDefaultBackendNameForTables("rdbms");
+`crm_contact`, `crm_company`, `crm_deal`, `crm_activity`, `crm_form_submission`, `crm_email_list`.
- new CrmQBitProducer()
- .withQBitConfig(crmConfig)
- .produce(qInstance)
- .addSelfToInstance(qInstance);
+### Settings tables (15)
- //////////
- // App //
- //////////
- qInstance.addApp(new QAppMetaData()
- .withName("myApp")
- .withSections(List.of(
- CrmQBitProducer.produceAppSection()
- )));
-}
-```
+`crm_pipeline`, `crm_product`, `crm_email_template`, `crm_email_sequence`, `crm_lead_score_rule`, `crm_assignment_rule`, `crm_sales_goal`, `crm_currency`, `crm_lifecycle_stage`, `crm_lead_source`, `crm_industry`, `crm_activity_type`, `crm_activity_outcome`, `crm_contact_role`, `crm_win_loss_reason`.
-## Data Model
+### Sub-tables (12)
-### Tables (39 total)
+`crm_pipeline_stage`, `crm_consent_record`, `crm_email_list_member`, `crm_email_message`, `crm_sequence_step`, `crm_sequence_enrollment`, `crm_deal_contact`, `crm_deal_product`, `crm_deal_stage_history`, `crm_activity_participant`, `crm_assignment_rule_member`, `crm_external_mapping`.
-#### Core (Phase 1a)
+Contact, company, deal and activity declare core FIELD-level audit rules. The bespoke tag tables, attachment table, audit-log table and bulk tag processes have been removed. 0.2.0 is a fresh-install release with no upgrade path from 0.1.0. Collaboration replacements arrive in a later phase.
-| Table | Description |
-|-------|-------------|
-| `crmContact` | People with lifecycle stages, lead scoring, consent tracking |
-| `crmCompany` | Organizations with industry, type, parent-child hierarchy |
-| `crmLifecycleStage` | Configurable contact lifecycle (Lead, MQL, SQL, Customer, etc.) |
-| `crmLeadSource` | How contacts were acquired (Website, Referral, etc.) |
-| `crmIndustry` | Industry classification for companies |
-| `crmTag` | Free-form tags for categorizing records |
-| `crmContactTag` / `crmCompanyTag` | Tag junction tables |
-| `crmAttachment` | File attachments on any entity (polymorphic) |
-| `crmConsentRecord` | GDPR consent tracking (append-only) |
-| `crmFormSubmission` | Website form capture with UTM parameters |
-| `crmEmailList` | Marketing mailing lists |
-| `crmEmailListMember` | List subscribers (standalone or linked to contacts) |
-| `crmCurrency` | Exchange rates for multi-currency support |
-| `crmAuditLog` | Immutable field-level change tracking |
+## Processes
-#### Activities
+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.
-| Table | Description |
-|-------|-------------|
-| `crmActivity` | Unified table for calls, emails, meetings, notes, tasks |
-| `crmActivityType` | Configurable activity types with icons |
-| `crmActivityOutcome` | Call/meeting outcomes (Connected, No Answer, etc.) |
-| `crmActivityParticipant` | Additional participants on activities |
+| 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 |
-#### Deals & Pipeline (Phase 1b)
+## Current behavior and limits
-| Table | Description |
-|-------|-------------|
-| `crmPipeline` | Pipeline definitions (multiple pipelines supported) |
-| `crmPipelineStage` | Stages with probability, rot days, required-field gating |
-| `crmDeal` | Deals with multi-currency, weighted amounts, probability override |
-| `crmDealContact` | Contact-to-deal junction with roles and primary flag |
-| `crmContactRole` | Roles contacts play on deals (Decision Maker, etc.) |
-| `crmWinLossReason` | Win/loss reasons for closed deals |
-| `crmDealTag` | Deal tag junction |
-| `crmDealStageHistory` | Stage transition log for pipeline analytics |
+The existing pipeline/stage logic, stage history, product totals, currency conversion, contact/company merge, activity logging, form auto-linking, list subscribe/unsubscribe, scoring and assignment processes remain. Fourteen custom dashboard widget producers remain available for host placement, together with generated child-record widgets.
-#### Email (Phase 2)
+Some process names describe unfinished behavior inherited from 0.1.0:
-| Table | Description |
-|-------|-------------|
-| `crmEmailTemplate` | Reusable templates with merge field support |
-| `crmEmailMessage` | Tracked emails with open/click/bounce/spam detection |
+- 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.
+- 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.
-#### Products (Phase 2)
+The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and full app navigation are not part of this phase.
-| Table | Description |
-|-------|-------------|
-| `crmProduct` | Product catalog with SKU and pricing |
-| `crmDealProduct` | Line items on deals with quantity, discount, calculated totals |
+## Source layout
-#### Sequences & Scoring (Phase 3)
+`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/`.
-| Table | Description |
-|-------|-------------|
-| `crmEmailSequence` | Multi-step outreach sequences with send windows |
-| `crmSequenceStep` | Individual steps (email, call, task, wait) |
-| `crmSequenceEnrollment` | Contact enrollment tracking with failure handling |
-| `crmLeadScoreRule` | Scoring rules with dot-notation field paths |
-| `crmAssignmentRule` | Auto-assignment rules (round-robin, least-active, specific user) |
-| `crmAssignmentRuleMember` | Users in assignment pools |
-| `crmSalesGoal` | Revenue quotas per user per period |
-
-#### Sync (Phase 2)
-
-| Table | Description |
-|-------|-------------|
-| `crmExternalMapping` | Bidirectional sync mapping for external systems |
-
-### Enum PossibleValueSources (24)
-
-CrmEntityType, CrmCompanyType, CrmDealPriority, CrmActivityPriority, CrmDirection, CrmBounceType, CrmWinLossType, CrmParticipantRole, CrmSequenceStepType, CrmEnrollmentStatus, CrmEmailStatus, CrmScorableEntity, CrmScoreOperator, CrmAssignableEntity, CrmAssignStrategy, CrmConsentType, CrmConsentStatus, CrmLegalBasis, CrmGoalPeriod, CrmSyncStatus, CrmAuditAction, CrmFormType, CrmFormConversionStatus, CrmListMemberStatus
-
-## Processes (27)
-
-### Core Operations
-
-| Process | Description |
-|---------|-------------|
-| `logActivity` | Quick-log a call, meeting, note, or task from any record |
-| `convertFormToContact` | Convert website form submission to CRM contact |
-| `bulkConvertForms` | Batch convert multiple form submissions |
-| `subscribeToList` / `unsubscribeFromList` | Mailing list management |
-| `bulkAssignOwner` | Reassign record ownership in bulk |
-| `bulkAddTag` / `bulkRemoveTag` | Tag management in bulk |
-| `transferOwnership` | Transfer all records from one user to another |
-
-### Deal Operations
-
-| Process | Description |
-|---------|-------------|
-| `moveDealStage` | Move deal to new stage with required-field validation |
-| `reopenDeal` | Reopen a closed deal to an active stage |
-| `convertLead` | Convert contact to deal (atomic: lifecycle + deal + junction) |
-| `manageContactOnDeal` | Add/remove contacts with role and primary flag enforcement |
-| `cloneDeal` | Duplicate deal with contacts, products, and tags |
-
-### Email & Sequences
-
-| Process | Description |
-|---------|-------------|
-| `sendEmail` | Compose and send tracked email with template resolution |
-| `trackEmailEvent` | Webhook callback for open/click/bounce/spam events |
-| `enrollInSequence` | Enroll contacts in multi-step sequences |
-| `processSequenceSteps` | Scheduled: execute pending sequence steps (5 min interval) |
-| `unenrollFromSequence` | Remove contact from active sequence |
-
-### Scoring & Assignment
-
-| Process | Description |
-|---------|-------------|
-| `calculateLeadScores` | Scheduled daily: recalculate scores from rules |
-| `autoAssign` | POST_INSERT hook: first-match rule-based assignment |
-
-### Data Management
-
-| Process | Description |
-|---------|-------------|
-| `mergeContacts` / `mergeCompanies` | Merge duplicates with unique-key conflict resolution |
-| `importContacts` | CSV import with validation and duplicate detection |
-| `detectDuplicates` | Find duplicate contacts by email or companies by domain |
-| `gdprAnonymizeContact` | Right-to-erasure: anonymize PII, scrub audit log, delete attachments |
-
-### Scheduled Maintenance
-
-| Process | Description |
-|---------|-------------|
-| `processOverdueTasks` | Hourly: detect overdue task activities |
-| `fireReminders` | Every 5 min: fire and clear activity reminders |
-
-## Widgets (14)
-
-| Widget | Type | Description |
-|--------|------|-------------|
-| `crmPipelineSummary` | Table | Deal count and amount per stage |
-| `crmActivityFeed` | Table | Most recent activities across all entities |
-| `crmDealForecast` | Table | Weighted pipeline by expected close month |
-| `crmRottingDeals` | Table | Deals exceeding stage rot threshold |
-| `crmContactTimeline` | Stepper | Per-contact activity timeline |
-| `crmActivityCount` | Statistics | Activity counts by type (today/week/month) |
-| `crmRecentDeals` | Table | Recently won/lost deals |
-| `crmSalesLeaderboard` | Table | Users ranked by closed-won revenue |
-| `crmQuotaAttainment` | Statistics | Won revenue vs. sales goal target |
-| `crmPipelineConversion` | Table | Stage-to-stage conversion rates |
-| `crmStageDuration` | Table | Average days per pipeline stage |
-| `crmWinRate` | Table | Win rate trend over time |
-| `crmAvgDealSize` | Table | Average won deal size |
-| `crmSalesCycleLength` | Table | Average days from creation to close |
-
-## Companion QBit Integration
-
-### qbit-quick-search (ElasticSearch)
-
-Index `crmContact`, `crmCompany`, and `crmDeal` for full-text global search.
-
-### qbit-webhooks
-
-Supported event types:
-
-| Event | Fires When |
-|-------|------------|
-| `crm.contact.created` / `crm.contact.updated` | Contact inserted or updated |
-| `crm.company.created` / `crm.company.updated` | Company inserted or updated |
-| `crm.deal.created` / `crm.deal.stageChanged` | Deal created or stage changed |
-| `crm.deal.won` / `crm.deal.lost` | Deal moved to terminal stage |
-| `crm.activity.created` | Activity logged |
-| `crm.consent.changed` | Consent granted or withdrawn |
-| `crm.form.submitted` / `crm.form.converted` | Form submitted or converted to contact |
-
-### qbit-workflows
-
-User-defined automation beyond built-in scoring/assignment. The CRM does not re-implement a workflow engine.
-
-## Phased Delivery
-
-| Phase | Tables | Processes | Widgets | Focus |
-|-------|--------|-----------|---------|-------|
-| 1a | 18 | 7 | 6 | Core + Website Integration |
-| 1b | 8 | 4 | 3 | Deals + Pipeline |
-| 2 | 5 | 7 | 1 | Email, Products, Scheduling |
-| 3 | 7 | 5 | 2 | Sequences, Scoring, Automation |
-| 4 | 1 | 7 | 5 | Data Management, Analytics, GDPR |
-| **Total** | **39** | **30** | **14** | |
-
-## Testing
-
-```bash
-mvn test # Run all 271 tests
-mvn test -Dtest=BaseTest # Verify QBit produces metadata
-```
-
-### Coverage
-
-- **91.5% instruction coverage** (Jacoco)
-- **73 test classes** covering all entities, processes, widgets, and customizers
-- All tests run in-memory via MemoryRecordStore (no database required)
-
-```bash
-mvn org.jacoco:jacoco-maven-plugin:0.8.11:prepare-agent test \
- org.jacoco:jacoco-maven-plugin:0.8.11:report
-# Report at: target/site/jacoco/index.html
-```
-
-## Documentation
-
-- **[QQQ Wiki](https://github.com/Kingsrook/qqq/wiki)** - Framework documentation
-- **[QBit Development Guide](https://github.com/Kingsrook/qqq/wiki/QBit-Development)** - How QBits work
-- **Design Spec** - Full data model and architecture in `QRun-IO/specs/2026-03-28-qbit-crm-design-v2.md`
-
-## Contributing
-
-QBit CRM is open source and welcomes contributions.
-
-- **[Report Issues](https://github.com/QRun-IO/qqq/issues)** - Bug reports and feature requests
-- **[QQQ Contribution Guide](https://github.com/Kingsrook/qqq/wiki/Contribution-Guidelines)** - How to contribute
-
-## About Kingsrook
-
-QBit CRM is built by **[Kingsrook](https://qrun.io)** - making engineers more productive through intelligent automation and developer tools.
-
-- **Website**: [https://qrun.io](https://qrun.io)
-- **Contact**: [contact@kingsrook.com](mailto:contact@kingsrook.com)
-- **GitHub**: [https://github.com/QRun-IO](https://github.com/QRun-IO)
-
-## License
-
-This project is licensed under the **Apache License, Version 2.0** - see the [LICENSE](LICENSE) file for details.
+Licensed under [Apache License 2.0](LICENSE).
diff --git a/checkstyle/config.xml b/checkstyle/config.xml
index 78c2f49..385d653 100644
--- a/checkstyle/config.xml
+++ b/checkstyle/config.xml
@@ -6,18 +6,17 @@
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
- ~ This program is free software: you can redistribute it and/or modify
- ~ it under the terms of the GNU Affero General Public License as
- ~ published by the Free Software Foundation, either version 3 of the
- ~ License, or (at your option) any later version.
+ ~ 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
~
- ~ This program is distributed in the hope that it will be useful,
- ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
- ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- ~ GNU Affero General Public License for more details.
+ ~ https://www.apache.org/licenses/LICENSE-2.0
~
- ~ You should have received a copy of the GNU Affero General Public License
- ~ along with this program. If not, see .
+ ~ 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.
-->
&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
index bbed79e..5fa2879 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,13 +22,18 @@
- 0.1.0
+ 0.2.0-SNAPSHOT
+ 21
+ 21
+ 0.8.15
+ true
+ true
com.kingsrook
qbit-build-parent
- 1.4.0
+ 2.1.2
com.kingsrook.qbits
@@ -82,7 +87,7 @@
com.h2database
h2
- 2.2.220
+ 2.3.232
test
@@ -95,9 +100,101 @@
org.assertj
assertj-core
- 3.27.3
+ 3.27.7
test
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+ 3.6.0
+
+ checkstyle/config.xml
+ true
+ warning
+
+
+
+ checkstyle
+ validate
+ check
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+ prepare-agent
+
+ prepare-agent
+
+
+
+ report
+ verify
+
+ report
+
+
+
+ check
+
+ check
+
+
+
+
+ BUNDLE
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ 0.70
+
+
+ CLASS
+ COVEREDRATIO
+ 0.90
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ qqq-snapshot
+
+ true
+
+
+ 4.1.0-RC.6
+
+
+
+
+ com.kingsrook.qqq
+ qqq-bom-pom
+ ${qqq.snapshot.version}
+ pom
+ import
+
+
+
+
+
diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java
index 9475b2d..053065c 100644
--- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java
@@ -1,37 +1,54 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** Configuration for the CRM QBit.
*******************************************************************************/
+
package com.kingsrook.qbits.crm;
-import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
-import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** QBitConfig for the CRM QBit. Holds backend name, security configuration,
- ** table metadata customizer, and companion QBit namespace references.
+ ** table metadata customizer and backend column naming.
*******************************************************************************/
public class CrmQBitConfig implements QBitConfig
{
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
private String defaultBackendNameForTables;
- private String tableNamePrefix;
+ private Boolean inferBackendNames = true;
private MetaDataCustomizerInterface tableMetaDataCustomizer;
private List securityFields;
private List recordSecurityLocks;
- private String quickSearchQBitNamespace;
- private String webhooksQBitNamespace;
- private String workflowsQBitNamespace;
-
/*******************************************************************************
@@ -110,27 +127,6 @@ public class CrmQBitConfig implements QBitConfig
- /*******************************************************************************
- ** Getter for tableNamePrefix
- *******************************************************************************/
- public String getTableNamePrefix()
- {
- return (this.tableNamePrefix);
- }
-
-
-
- /*******************************************************************************
- ** Fluent setter for tableNamePrefix
- *******************************************************************************/
- public CrmQBitConfig withTableNamePrefix(String tableNamePrefix)
- {
- this.tableNamePrefix = tableNamePrefix;
- return (this);
- }
-
-
-
/*******************************************************************************
** Getter for securityFields
*******************************************************************************/
@@ -174,63 +170,21 @@ public class CrmQBitConfig implements QBitConfig
/*******************************************************************************
- ** Getter for quickSearchQBitNamespace
+ ** Whether to infer snake_case database column names.
*******************************************************************************/
- public String getQuickSearchQBitNamespace()
+ public Boolean getInferBackendNames()
{
- return (this.quickSearchQBitNamespace);
+ return (this.inferBackendNames);
}
/*******************************************************************************
- ** Fluent setter for quickSearchQBitNamespace
+ ** Configure automatic database column naming.
*******************************************************************************/
- public CrmQBitConfig withQuickSearchQBitNamespace(String quickSearchQBitNamespace)
+ public CrmQBitConfig withInferBackendNames(Boolean inferBackendNames)
{
- this.quickSearchQBitNamespace = quickSearchQBitNamespace;
- return (this);
- }
-
-
-
- /*******************************************************************************
- ** Getter for webhooksQBitNamespace
- *******************************************************************************/
- public String getWebhooksQBitNamespace()
- {
- return (this.webhooksQBitNamespace);
- }
-
-
-
- /*******************************************************************************
- ** Fluent setter for webhooksQBitNamespace
- *******************************************************************************/
- public CrmQBitConfig withWebhooksQBitNamespace(String webhooksQBitNamespace)
- {
- this.webhooksQBitNamespace = webhooksQBitNamespace;
- return (this);
- }
-
-
-
- /*******************************************************************************
- ** Getter for workflowsQBitNamespace
- *******************************************************************************/
- public String getWorkflowsQBitNamespace()
- {
- return (this.workflowsQBitNamespace);
- }
-
-
-
- /*******************************************************************************
- ** Fluent setter for workflowsQBitNamespace
- *******************************************************************************/
- public CrmQBitConfig withWorkflowsQBitNamespace(String workflowsQBitNamespace)
- {
- this.workflowsQBitNamespace = workflowsQBitNamespace;
+ this.inferBackendNames = inferBackendNames;
return (this);
}
diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java
index df915c9..1ccdec2 100644
--- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java
+++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java
@@ -1,31 +1,61 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** QBitMetaDataProducer for the CRM QBit.
*******************************************************************************/
+
package com.kingsrook.qbits.crm;
-import java.util.List;
+import java.util.Locale;
import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer;
-import com.kingsrook.qbits.crm.core.model.FormSubmission;
-import com.kingsrook.qbits.crm.audit.customizers.CrmAuditLogCustomizer;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
+import com.kingsrook.qbits.crm.core.model.FormSubmission;
import com.kingsrook.qbits.crm.deals.customizers.DealInitializationCustomizer;
import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomizer;
import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealProduct;
+import com.kingsrook.qbits.crm.security.CrmPermissions;
+import com.kingsrook.qbits.crm.widgets.CrmWidgetMetaData;
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
+import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
+import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
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.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -39,7 +69,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "crm";
- public static final String VERSION = "0.1.0";
+ public static final String VERSION = "0.2.0-SNAPSHOT";
private CrmQBitConfig qBitConfig;
@@ -67,12 +97,48 @@ public class CrmQBitProducer implements QBitMetaDataProducer
@Override
public void postProduceActions(MetaDataProducerMultiOutput metaDataProducerMultiOutput, QInstance qInstance) throws QException
{
+ for(QJoinMetaData join : metaDataProducerMultiOutput.getEach(QJoinMetaData.class))
+ {
+ join.setName(snakeCase(join.getName()));
+ }
+ for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
+ {
+ widget.setName(snakeCase(widget.getName()));
+ widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE));
+ if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
+ {
+ widget.withDefaultValue("joinName", snakeCase(joinName));
+ }
+ }
+
+ for(QProcessMetaData process : metaDataProducerMultiOutput.getEach(QProcessMetaData.class))
+ {
+ process.setPermissionRules(CrmPermissions.processRules(process.getName()));
+ process.setSourceQBitName(getQBitMetaData().getName());
+ }
+
/////////////////////////////////////////////////////////////////////////
// iterate tables produced by this QBit and register customizers //
/////////////////////////////////////////////////////////////////////////
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
{
String tableName = table.getName();
+ table.setPermissionRules(CrmPermissions.tableRules(tableName));
+ table.setSourceQBitName(getQBitMetaData().getName());
+ if(table.getSections() != null)
+ {
+ table.getSections().forEach(section ->
+ {
+ if(section.getWidgetName() != null)
+ {
+ section.setWidgetName(snakeCase(section.getWidgetName()));
+ }
+ });
+ }
+ if(Boolean.TRUE.equals(qBitConfig.getInferBackendNames()))
+ {
+ QInstanceEnricher.setInferredFieldBackendNames(table);
+ }
///////////////////////////////////////////////////////////////
// wire audit log on tracked entities //
@@ -80,10 +146,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer
if(tableName.equals(Contact.TABLE_NAME) || tableName.equals(Company.TABLE_NAME)
|| tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
{
- QCodeReference auditRef = new QCodeReference(CrmAuditLogCustomizer.class);
- addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, auditRef);
- addOrComposeCustomizer(table, TableCustomizers.POST_UPDATE_RECORD, auditRef);
- addOrComposeCustomizer(table, TableCustomizers.POST_DELETE_RECORD, auditRef);
+ table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
}
///////////////////////////////////////////////////////////////
@@ -209,4 +272,28 @@ public class CrmQBitProducer implements QBitMetaDataProducer
return (this);
}
+ /*******************************************************************************
+ ** Normalize generated metadata names as well as explicit CRM names.
+ *******************************************************************************/
+ private static String snakeCase(String name)
+ {
+ return (name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT));
+ }
+
+ /*******************************************************************************
+ ** Add widget provenance after the standard package-discovered production.
+ *******************************************************************************/
+ @Override
+ public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
+ {
+ MetaDataProducerMultiOutput produced = QBitMetaDataProducer.super.produce(qInstance);
+ MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
+ for(MetaDataProducerOutput item : produced.getEach(MetaDataProducerOutput.class))
+ {
+ output.add(item instanceof QWidgetMetaData widget
+ ? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
+ }
+ return (output);
+ }
+
}
diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java b/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java
index ffd574a..dc3a016 100644
--- a/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java
+++ b/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java
@@ -1,7 +1,29 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** Utility class for null-safe session user ID resolution. Used by processes
** and customizers that need the current user ID for audit logging.
*******************************************************************************/
+
package com.kingsrook.qbits.crm;
diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java
index a8bac86..d66d903 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java
@@ -1,8 +1,30 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** POST_INSERT table customizer that propagates lastActivityDate and
** lastContactedDate to linked Contact, Company, and Deal records whenever
** a new Activity is created.
*******************************************************************************/
+
package com.kingsrook.qbits.crm.activities.customizers;
diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java
index 8593262..114c6e9 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java
@@ -1,6 +1,28 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** QRecord Entity for the crm_activity table.
*******************************************************************************/
+
package com.kingsrook.qbits.crm.activities.model;
@@ -57,7 +79,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
)
public class Activity extends QRecordEntity
{
- public static final String TABLE_NAME = "crmActivity";
+ public static final String TABLE_NAME = "crm_activity";
diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityOutcome.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityOutcome.java
index 3ac6255..dd948c2 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityOutcome.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityOutcome.java
@@ -1,6 +1,28 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** QRecord Entity for the crm_activity_outcome table.
*******************************************************************************/
+
package com.kingsrook.qbits.crm.activities.model;
@@ -30,7 +52,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
)
public class ActivityOutcome extends QRecordEntity
{
- public static final String TABLE_NAME = "crmActivityOutcome";
+ public static final String TABLE_NAME = "crm_activity_outcome";
diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityParticipant.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityParticipant.java
index 822630a..18a026e 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityParticipant.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityParticipant.java
@@ -1,6 +1,28 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** QRecord Entity for the crm_activity_participant table.
*******************************************************************************/
+
package com.kingsrook.qbits.crm.activities.model;
@@ -29,7 +51,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
)
public class ActivityParticipant extends QRecordEntity
{
- public static final String TABLE_NAME = "crmActivityParticipant";
+ public static final String TABLE_NAME = "crm_activity_participant";
diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java
index aef48f3..6494195 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java
@@ -1,6 +1,28 @@
+/*
+ * 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.
+ */
+
+
/*******************************************************************************
** QRecord Entity for the crm_activity_type table.
*******************************************************************************/
+
package com.kingsrook.qbits.crm.activities.model;
@@ -34,7 +56,7 @@ import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage
)
public class ActivityType extends QRecordEntity
{
- public static final String TABLE_NAME = "crmActivityType";
+ public static final String TABLE_NAME = "crm_activity_type";
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
index a03b9af..379f0ed 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcess.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/processes/FireRemindersProcess.java
@@ -1,7 +1,29 @@
+/*
+ * 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;
@@ -37,7 +59,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
*******************************************************************************/
public class FireRemindersProcess implements BackendStep, MetaDataProducerInterface
{
- public static final String NAME = "fireReminders";
+ public static final String NAME = "crm_fire_reminders";
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 29db789..8183992 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
@@ -1,7 +1,29 @@
+/*
+ * 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 log a CRM activity. Creates a single crm_activity record with
** the provided fields and sets ownerUserId from the current session user.
*******************************************************************************/
+
package com.kingsrook.qbits.crm.activities.processes;
@@ -16,11 +38,11 @@ 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;
import com.kingsrook.qqq.backend.core.exceptions.QException;
-import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
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.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;
@@ -37,7 +59,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
*******************************************************************************/
public class LogActivityProcess implements BackendStep, MetaDataProducerInterface
{
- public static final String NAME = "logActivity";
+ public static final String NAME = "crm_log_activity";
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
index 2055958..6dfc072 100644
--- a/src/main/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcess.java
+++ b/src/main/java/com/kingsrook/qbits/crm/activities/processes/ProcessOverdueTasksProcess.java
@@ -1,8 +1,30 @@
+/*
+ * 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;
@@ -38,7 +60,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
*******************************************************************************/
public class ProcessOverdueTasksProcess implements BackendStep, MetaDataProducerInterface
{
- public static final String NAME = "processOverdueTasks";
+ public static final String NAME = "crm_process_overdue_tasks";
diff --git a/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java
deleted file mode 100644
index 4c2f376..0000000
--- a/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java
+++ /dev/null
@@ -1,290 +0,0 @@
-/*******************************************************************************
- ** POST_INSERT, POST_UPDATE, and POST_DELETE table customizer that creates
- ** immutable audit log entries for tracked CRM entities.
- **
- ** Registered globally on tracked tables (contact, company, deal, activity)
- ** via MultiCustomizer in CrmQBitProducer.postProduceActions.
- *******************************************************************************/
-package com.kingsrook.qbits.crm.audit.customizers;
-
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import com.kingsrook.qbits.crm.audit.model.AuditLog;
-import com.kingsrook.qbits.crm.core.model.enums.CrmAuditAction;
-import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
-import com.kingsrook.qbits.crm.CrmSessionUtils;
-import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
-import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
-import com.kingsrook.qqq.backend.core.exceptions.QException;
-import com.kingsrook.qqq.backend.core.logging.QLogger;
-import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
-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.utils.StringUtils;
-import com.kingsrook.qqq.backend.core.utils.ValueUtils;
-
-
-/*******************************************************************************
- ** Audit log customizer that creates crm_audit_log entries for every insert,
- ** update, and delete on tracked entities. For updates, field-level change
- ** tracking captures old and new values, with special detection for owner
- ** and pipeline stage changes.
- *******************************************************************************/
-public class CrmAuditLogCustomizer implements TableCustomizerInterface
-{
- private static final QLogger LOG = QLogger.getLogger(CrmAuditLogCustomizer.class);
-
- ///////////////////////////////////////////////////////////////////////////
- // map of table names to CrmEntityType IDs for tracked entities //
- ///////////////////////////////////////////////////////////////////////////
- private static final Map TABLE_TO_ENTITY_TYPE = new HashMap<>();
-
- static
- {
- TABLE_TO_ENTITY_TYPE.put("crmContact", CrmEntityType.CONTACT.getPossibleValueId());
- TABLE_TO_ENTITY_TYPE.put("crmCompany", CrmEntityType.COMPANY.getPossibleValueId());
- TABLE_TO_ENTITY_TYPE.put("crmDeal", CrmEntityType.DEAL.getPossibleValueId());
- TABLE_TO_ENTITY_TYPE.put("crmActivity", CrmEntityType.ACTIVITY.getPossibleValueId());
- }
-
-
-
- /*******************************************************************************
- ** Post-insert: log a CREATED action for each inserted record.
- *******************************************************************************/
- @Override
- public List postInsert(InsertInput insertInput, List records) throws QException
- {
- String tableName = insertInput.getTableName();
- String userId = getCurrentUserId();
-
- List auditRecords = new ArrayList<>();
- for(QRecord record : records)
- {
- if(record.getErrors() != null && !record.getErrors().isEmpty())
- {
- continue;
- }
-
- Integer entityId = ValueUtils.getValueAsInteger(record.getValue("id"));
- if(entityId == null)
- {
- continue;
- }
-
- auditRecords.add(buildAuditRecord(tableName, entityId, CrmAuditAction.CREATED, userId,
- null, null, null, "Record created"));
- }
-
- insertAuditRecords(auditRecords);
- return (records);
- }
-
-
-
- /*******************************************************************************
- ** Post-update: log an UPDATED action per changed field, with special
- ** detection for OWNER_CHANGED and STAGE_CHANGED actions.
- *******************************************************************************/
- @Override
- public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException
- {
- String tableName = updateInput.getTableName();
- String userId = getCurrentUserId();
-
- Optional