Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d41fb3f7
|
||
|
|
a93365a7b7
|
||
|
|
15a2549b59
|
||
|
|
831bc59743 | ||
|
|
7a9a38afe4
|
||
|
|
45df8cd380 | ||
|
|
43c6fe83b1
|
||
|
|
0da0edfc6c
|
||
|
|
b8dc4041a7
|
||
|
|
8381850999
|
||
|
|
85b83c8031
|
||
|
|
fc92690573
|
||
|
|
2903a1e546
|
||
|
|
bd4b6fd9c4
|
||
|
|
6acd5ea6e2
|
||
|
|
fc2effb070
|
||
|
|
c35b3478ca
|
||
|
|
6ecaa4c255 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# QBit CRM
|
||||
|
||||
**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.
|
||||
**0.2.0-SNAPSHOT — unreleased, P1 Platform + P2 App, home and nav.** CRM tables, processes and widgets for QQQ applications. This work upgrades the build, names, permissions, audit integration and database schema, and supplies the CRM app and record navigation. It does not complete the later 0.2.0 product phases.
|
||||
|
||||
## Build and verification
|
||||
|
||||
@@ -50,7 +50,7 @@ new CrmQBitProducer()
|
||||
qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
```
|
||||
|
||||
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.
|
||||
4. Map the host's authenticated roles to the permissions below. Configure the host's core audit storage. The `crm` app is enabled by default, with main and Settings navigation and a real-data home. Set `withAppEnabled(false)` to embed the tables in the host's own app. `produceAppSection()` returns the same populated main section; processes are record/table actions, never navigation entries.
|
||||
|
||||
## Configuration and access
|
||||
|
||||
@@ -68,7 +68,7 @@ qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
| Merge, bulk assignment, list and sequence management processes | `crm.manage.hasAccess` |
|
||||
| GDPR anonymization | `crm.admin.hasAccess` |
|
||||
|
||||
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.
|
||||
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The app and widget provenance adapters supply core's `SourceQBitAware` interface because RC.6's app and widget classes lack it.
|
||||
|
||||
## Tables
|
||||
|
||||
@@ -90,51 +90,154 @@ Contact, company, deal and activity declare core FIELD-level audit rules. The be
|
||||
|
||||
## Processes
|
||||
|
||||
The QBit produces 25 processes with these names and labels. QQQ may additionally generate standard table actions such as `<table>.bulkInsert`; those retain core's naming conventions. CRM processes are available to host wiring and are not registered as scheduled jobs.
|
||||
The QBit produces 28 table-bound record actions. Every action uses the standard
|
||||
streamed ETL Review and Result screens with linked summary lines. Required inputs
|
||||
are collected before Review. Processes never appear in app navigation. Find
|
||||
Duplicates scans the selected records (select all matching records to scan the
|
||||
table); each duplicate group links to its members. Merges require exactly two
|
||||
selected records and a choice of which record to keep.
|
||||
|
||||
| Process name | Label |
|
||||
|---|---|
|
||||
| `crm_auto_assign` | Auto Assign |
|
||||
| `crm_bulk_assign_owner` | Bulk Assign Owner |
|
||||
| `crm_calculate_lead_scores` | Calculate Lead Scores |
|
||||
| `crm_clone_deal` | Clone Deal |
|
||||
| `crm_convert_form_to_contact` | Convert Form to Contact |
|
||||
| `crm_convert_lead` | Convert Lead |
|
||||
| `crm_detect_duplicates` | Detect Duplicates |
|
||||
| `crm_enroll_in_sequence` | Enroll in Sequence |
|
||||
| `crm_fire_reminders` | Fire Reminders |
|
||||
| `crm_gdpr_anonymize_contact` | GDPR Anonymize Contact |
|
||||
| `crm_import_contacts` | Import Contacts |
|
||||
| `crm_log_activity` | Log Activity |
|
||||
| `crm_manage_contact_on_deal` | Manage Contact on Deal |
|
||||
| `crm_merge_companies` | Merge Companies |
|
||||
| `crm_merge_contacts` | Merge Contacts |
|
||||
| `crm_move_deal_stage` | Move Deal Stage |
|
||||
| `crm_process_overdue_tasks` | Process Overdue Tasks |
|
||||
| `crm_process_sequence_steps` | Process Sequence Steps |
|
||||
| `crm_reopen_deal` | Reopen Deal |
|
||||
| `crm_send_email` | Send Email |
|
||||
| `crm_subscribe_to_list` | Subscribe to List |
|
||||
| `crm_track_email_event` | Track Email Event |
|
||||
| `crm_transfer_ownership` | Transfer Ownership |
|
||||
| `crm_unenroll_from_sequence` | Unenroll from Sequence |
|
||||
| `crm_unsubscribe_from_list` | Unsubscribe from List |
|
||||
| Table | Process name | Label |
|
||||
|---|---|---|
|
||||
| `crm_contact` | `crm_bulk_assign_owner` | Assign Owner |
|
||||
| `crm_contact` | `crm_auto_assign` | Auto-Assign |
|
||||
| `crm_contact` | `crm_subscribe_to_list` | Add to List |
|
||||
| `crm_contact` | `crm_unsubscribe_from_list` | Remove from List |
|
||||
| `crm_contact` | `crm_enroll_in_sequence` | Enroll in Sequence |
|
||||
| `crm_contact` | `crm_unenroll_from_sequence` | Unenroll from Sequence |
|
||||
| `crm_contact` | `crm_log_activity` | Log Activity |
|
||||
| `crm_contact` | `crm_recalculate_score` | Recalculate Score |
|
||||
| `crm_contact` | `crm_merge_contacts` | Merge Contacts |
|
||||
| `crm_contact` | `crm_detect_duplicates` | Find Duplicates |
|
||||
| `crm_contact` | `crm_convert_lead` | Create Deal |
|
||||
| `crm_contact` | `crm_gdpr_anonymize_contact` | Erase Personal Data |
|
||||
| `crm_contact` | `crm_send_email` | Send Email |
|
||||
| `crm_company` | `crm_assign_company_owner` | Assign Owner |
|
||||
| `crm_company` | `crm_merge_companies` | Merge Companies |
|
||||
| `crm_company` | `crm_find_duplicate_companies` | Find Duplicates |
|
||||
| `crm_company` | `crm_log_company_activity` | Log Activity |
|
||||
| `crm_deal` | `crm_move_deal_stage` | Move Stage |
|
||||
| `crm_deal` | `crm_mark_deal_won` | Mark Won |
|
||||
| `crm_deal` | `crm_mark_deal_lost` | Mark Lost |
|
||||
| `crm_deal` | `crm_reopen_deal` | Reopen |
|
||||
| `crm_deal` | `crm_clone_deal` | Clone |
|
||||
| `crm_deal` | `crm_transfer_ownership` | Assign Owner |
|
||||
| `crm_deal` | `crm_manage_contact_on_deal` | Add Contact |
|
||||
| `crm_deal` | `crm_log_deal_activity` | Log Activity |
|
||||
| `crm_form_submission` | `crm_convert_form_to_contact` | Convert to Contact |
|
||||
| `crm_email_sequence` | `crm_activate_sequence` | Activate |
|
||||
| `crm_email_sequence` | `crm_pause_sequence` | Pause |
|
||||
|
||||
Move Stage and Reopen choose a stage from the selected deals' pipeline. Select
|
||||
one pipeline at a time for those actions. Mark Won and Mark Lost require a
|
||||
win/loss reason and resolve the terminal stage within each deal's pipeline.
|
||||
|
||||
Two hidden background processes have no table binding: `crm_process_sequence_steps`
|
||||
and `crm_calculate_lead_scores`. Both use id keyset pages of 200. They have no
|
||||
shipped schedules yet; the host can invoke them. QQQ also generates standard
|
||||
bulk-load and bulk-edit actions such as `<table>.bulkInsert`.
|
||||
|
||||
Fire Reminders and Process Overdue Tasks were removed in favor of collaboration
|
||||
tasks and alerts. Import Contacts was removed in favor of core bulk load. Track
|
||||
Email Event was removed in favor of the later delivery sync.
|
||||
|
||||
## Current behavior and limits
|
||||
|
||||
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.
|
||||
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. The CRM home contains Today (five standard statistics), Pipeline by stage, Forecast (current month plus five), Needs attention (ten oldest rotting deals), Recent web leads (latest ten), Goals (up to fifty current goals, hidden when none are visible), and Leaderboard (quarter-to-date won revenue by owner). Reporting boundaries use UTC. Monetary summaries use base currency, with weighted amounts grouped by currency before conversion. Aggregate rows link to their underlying records; stage name and deal count share a column to keep tables to three columns. The pipeline picker appears when multiple active pipelines are visible.
|
||||
|
||||
Some process names describe unfinished behavior inherited from 0.1.0:
|
||||
The contact Timeline shows the latest fifty recorded events from activities, email messages, form submissions and consent, newest first by creation date. Native child lists connect all specified parent screens. Deal primary-contact and email-message contact references are included in the fresh-install schema. The trend widgets, redundant recent-deals/activity-count widgets and activity feed have been retired.
|
||||
|
||||
- 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.
|
||||
Later phases complete the following behavior:
|
||||
|
||||
- Send Email is a non-sending action shell. It checks for `CrmEmailTransport` with `Class.forName` and reports **Email sending arrives with the email transport** when absent. It never writes a fake sent message or activity. If the class is present, the shell reports that action integration is still pending. Sequence email steps retain the existing activity-only behavior until transport integration.
|
||||
- Sequence execution and lead scoring require host invocation. No schedules are shipped. Assignment remains an action; automatic insert hooks, collaboration and alert integration arrive later.
|
||||
- Activate/Pause update the sequence active flag. The existing sequence runner's business logic is preserved in P2B and does not yet consult that flag; enforcing it during execution is a follow-up.
|
||||
- Scoring's date-decay operators remain unimplemented. Core bulk load is the import path; saved CRM import profiles arrive later.
|
||||
- GDPR anonymization scrubs the contact, unenrolls sequences and records withdrawn consent. It does not yet erase all linked PII or core audit details. The P1 schema retains existing PII fields; classification, complete erasure and redesigned tenancy are later work.
|
||||
|
||||
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and full app navigation are not part of this phase.
|
||||
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 security/integration work are not part of this phase.
|
||||
|
||||
## Source layout
|
||||
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
`CrmQBitProducer` discovers entity, process and widget producers. `core/` holds contacts, companies, forms, lists and consent; `deals/`, `activities/`, `email/` and `scoring/` hold their respective metadata and behavior. `actions/` defines the streamed record actions and their inputs/summaries. `security/` defines permissions, `sync/` holds external mappings and currency utilities, and `widgets/` contains widget producers/renderers. SQL is shipped under `src/main/resources/db/liquibase/qbit-crm/`.
|
||||
|
||||
Licensed under [Apache License 2.0](LICENSE).
|
||||
|
||||
## Integrating: intake and marketing APIs
|
||||
|
||||
Use `com.kingsrook.qbits.crm.api` from a host with an initialized `QContext`.
|
||||
The API enforces the current session's CRM permissions and record locks; it does
|
||||
not elevate website callers. The host supplies authentication, tenant scope and
|
||||
abuse protection before invoking intake. First use initializes the six activity
|
||||
codes through normal table actions and needs CRM settings write access.
|
||||
|
||||
Configure the signing secret from your secret store and the externally reachable
|
||||
base URL. Never put the secret in source control:
|
||||
|
||||
```java
|
||||
CrmQBitConfig config = new CrmQBitConfig()
|
||||
.withDefaultBackendNameForTables("main")
|
||||
.withTokenSecret(secretFromHost)
|
||||
.withPublicBaseUrl("https://example.com")
|
||||
.withMessagingProviderName("email"); // core fallback when qbit-email is absent
|
||||
```
|
||||
|
||||
`CrmApi.isInstalled(instance)` checks installation. `CrmApi.readiness()` returns
|
||||
`NOT_CONFIGURED`, `QBIT_EMAIL` or `CORE_MESSAGING`. qbit-email 0.1.0-RC.3 is an
|
||||
optional dependency, preferred when installed; its own readiness and suppression
|
||||
rules apply. Core fallback uses the named host messaging provider. The core path
|
||||
has no durable send deduplication; qbit-email uses `sendOnce` with
|
||||
`crm:<purpose>:<id>`. A refreshed confirmation uses a new token hash in that id.
|
||||
|
||||
```java
|
||||
CrmIntakeApi intake = new CrmIntakeApi();
|
||||
CrmFormResult lead = intake.submitForm(new CrmFormRequest(
|
||||
"contact", FormType.CONTACT, "Ada", "Lovelace", "ada@example.com",
|
||||
null, "Example", "Please contact me", false, "Reply to my inquiry",
|
||||
"search", "organic", "launch", null, null,
|
||||
"https://example.com/contact", null, null, null));
|
||||
|
||||
CrmSubscribeResult signup = intake.subscribe(new CrmSubscribeRequest(
|
||||
"newsletter", "ada@example.com", "Ada", "Lovelace",
|
||||
"Send me the monthly newsletter", "Website",
|
||||
"search", "organic", "launch", null, null, null, null));
|
||||
```
|
||||
|
||||
Create an active `crm_email_list` with its required unique `listKey` before
|
||||
subscribing. Double opt-in defaults to true. Pending members receive a signed
|
||||
confirmation link and become eligible only after confirmation. Requests retain
|
||||
the exact consent wording and submission evidence. Contact forms without an
|
||||
explicit opt-in record **Reply to inquiry / Legitimate interest**, never Email
|
||||
consent. Optional visitor/client identifiers must be precomputed SHA-256 hex
|
||||
hashes; salt the client hash in the host. Raw IPs and user-agent strings have no
|
||||
storage fields. Missing subscriber names use the label "Not provided" because
|
||||
the CRM contact model requires both names.
|
||||
|
||||
```java
|
||||
CrmMarketingApi marketing = new CrmMarketingApi();
|
||||
List<RecipientScopeOption> scopes = marketing.recipientScopeOptions();
|
||||
List<CrmRecipient> recipients = marketing.resolveRecipients("crm-list:1");
|
||||
Eligibility eligibility = marketing.checkEmailEligibility(lead.contactId());
|
||||
marketing.recordActivity(lead.contactId(), ActivityKind.NOTE, "Qualified lead", "Requested a demo");
|
||||
String unsubscribeUrl = marketing.buildUnsubscribeUrl(lead.contactId(), null);
|
||||
marketing.recordUnsubscribe(lead.contactId(), null, "Customer request");
|
||||
```
|
||||
|
||||
Recipient scopes are `crm-list:<id>` and `crm-contact:<id>`. Addresses are returned
|
||||
only when eligible. The latest Email consent must be granted and unexpired;
|
||||
`doNotEmail`, a global unsubscribe date, or any non-subscribed membership
|
||||
suppresses delivery. List-level withdrawal also appends a contact-level Email
|
||||
withdrawal, so it suppresses marketing until subsequent explicit consent; it
|
||||
does not set the contact's global unsubscribe date. The API never silently clears
|
||||
that global suppression. `listFormSubmissions(campaign, since)` returns submission
|
||||
and contact IDs plus timestamps, without form contents.
|
||||
|
||||
When Javalin is present, CRM registers GET and POST under
|
||||
`/qqq/hooks/crm/confirm/{token}` and `/qqq/hooks/crm/unsubscribe/{token}`.
|
||||
GET displays an accessible confirmation button; POST performs the operation.
|
||||
Links expire after 90 days. Confirmation tokens are stored only as SHA-256 hashes;
|
||||
refreshing or withdrawing a pending subscription invalidates its previous token.
|
||||
The signed route uses a system session solely for the IDs authorized by that link.
|
||||
Direct callers can also use `confirm(token)`, `unsubscribe(token)` and
|
||||
`unsubscribeByContact(contactId, listIdOrNull, source)`. Pages contain no personal
|
||||
data and responses disable caching and referrer forwarding.
|
||||
+40
-4
@@ -71,6 +71,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_activity_participant_contact_id ON crm_activit
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_activity_participant_user_id ON crm_activity_participant (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_activity_type (
|
||||
code VARCHAR(50) UNIQUE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
icon_name VARCHAR(50),
|
||||
@@ -144,6 +145,8 @@ CREATE INDEX IF NOT EXISTS ix_crm_company_owner_user_id ON crm_company (owner_us
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_company_parent_company_id ON crm_company (parent_company_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
consent_text TEXT,
|
||||
evidence_form_submission_id INTEGER,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
contact_id INTEGER NOT NULL,
|
||||
consent_type INTEGER NOT NULL,
|
||||
@@ -152,13 +155,28 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
source VARCHAR(250),
|
||||
consent_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
expiry_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_record (contact_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
first_touch_utm_source VARCHAR(250),
|
||||
first_touch_utm_medium VARCHAR(250),
|
||||
first_touch_utm_campaign VARCHAR(250),
|
||||
first_touch_utm_content VARCHAR(250),
|
||||
first_touch_utm_term VARCHAR(250),
|
||||
first_touch_landing_page VARCHAR(1000),
|
||||
first_touch_referrer VARCHAR(1000),
|
||||
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_touch_utm_source VARCHAR(250),
|
||||
last_touch_utm_medium VARCHAR(250),
|
||||
last_touch_utm_campaign VARCHAR(250),
|
||||
last_touch_utm_content VARCHAR(250),
|
||||
last_touch_utm_term VARCHAR(250),
|
||||
last_touch_landing_page VARCHAR(1000),
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
@@ -233,6 +251,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
|
||||
probability_override_pct INTEGER,
|
||||
weighted_amount DECIMAL(20,6),
|
||||
company_id INTEGER,
|
||||
primary_contact_id INTEGER,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
lead_source_id INTEGER,
|
||||
expected_close_date DATE,
|
||||
@@ -248,6 +267,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_id ON crm_deal (pipeline_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_stage_id ON crm_deal (pipeline_stage_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_primary_contact_id ON crm_deal (primary_contact_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_company_id ON crm_deal (company_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_owner_user_id ON crm_deal (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_lead_source_id ON crm_deal (lead_source_id);
|
||||
@@ -300,6 +320,9 @@ CREATE INDEX IF NOT EXISTS ix_crm_deal_stage_history_to_stage_id ON crm_deal_sta
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_stage_history_user_id ON crm_deal_stage_history (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
list_key VARCHAR(250) NOT NULL UNIQUE,
|
||||
require_double_opt_in BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
@@ -315,13 +338,18 @@ CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list_member (
|
||||
confirmation_token_hash VARCHAR(64),
|
||||
confirmation_sent_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
source VARCHAR(250),
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email_list_id INTEGER NOT NULL,
|
||||
contact_id INTEGER,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
status INTEGER DEFAULT 1 NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
unsubscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
subscription_source VARCHAR(250),
|
||||
form_submission_id INTEGER,
|
||||
@@ -337,6 +365,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
|
||||
CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
contact_id INTEGER,
|
||||
from_address VARCHAR(250) NOT NULL,
|
||||
from_name VARCHAR(100),
|
||||
to_addresses TEXT NOT NULL,
|
||||
@@ -363,6 +392,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_template_id ON crm_email_message (email_template_id);
|
||||
@@ -417,6 +447,12 @@ CREATE INDEX IF NOT EXISTS ix_crm_external_mapping_entity_id ON crm_external_map
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_external_mapping_external_id ON crm_external_mapping (external_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
form_key VARCHAR(250) NOT NULL,
|
||||
marketing_consent BOOLEAN DEFAULT FALSE,
|
||||
consent_text TEXT,
|
||||
landing_page VARCHAR(1000),
|
||||
visitor_key VARCHAR(64),
|
||||
client_hash VARCHAR(64),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
form_type INTEGER NOT NULL,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
@@ -436,8 +472,6 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
conversion_status INTEGER DEFAULT 1 NOT NULL,
|
||||
converted_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
converted_by_user_id TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
metadata TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
@@ -604,3 +638,5 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT uq_crm_win_loss_reason_1 UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
@@ -73,6 +73,17 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>0.1.0-RC.3</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qbits.crm.widgets.CrmHomeWidgets;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
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.qbits.SourceQBitAware;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CRM home and the two navigation sections. Child tables stay on records.
|
||||
*******************************************************************************/
|
||||
public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAware
|
||||
{
|
||||
private String sourceQBitName;
|
||||
|
||||
public static final List<String> MAIN_TABLES = List.of("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list");
|
||||
public static final List<String> SETTINGS_TABLES = List.of("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");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata factory only.
|
||||
*******************************************************************************/
|
||||
private CrmAppMetaData()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Main navigation, also available to hosts embedding these tables.
|
||||
*******************************************************************************/
|
||||
public static QAppSection mainSection()
|
||||
{
|
||||
return (new QAppSection().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
|
||||
.withTables(new ArrayList<>(MAIN_TABLES)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct the app after package discovery has produced the tables.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData build(MetaDataProducerMultiOutput output, String source)
|
||||
{
|
||||
QAppMetaData app = new CrmAppMetaData().withSourceQBitName(source).withName("crm").withLabel("CRM").withIcon(new QIcon("contacts"))
|
||||
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
|
||||
.withWidgets(new ArrayList<>(CrmHomeWidgets.HOME))
|
||||
.withSection(mainSection())
|
||||
.withSection(new QAppSection().withName("crm_settings").withLabel("Settings").withIcon(new QIcon("settings"))
|
||||
.withTables(new ArrayList<>(SETTINGS_TABLES)));
|
||||
List<String> tables = new ArrayList<>(MAIN_TABLES);
|
||||
tables.addAll(SETTINGS_TABLES);
|
||||
for(String name : tables)
|
||||
{
|
||||
app.withChild(output.get(QTableMetaData.class, name));
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit provenance, until core apps implement SourceQBitAware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getSourceQBitName()
|
||||
{
|
||||
return (sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit provenance, until core apps implement SourceQBitAware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit provenance, until core apps implement SourceQBitAware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public CrmAppMetaData withSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
setSourceQBitName(sourceQBitName);
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,15 @@ public class CrmQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig();
|
||||
|
||||
private String messagingProviderName;
|
||||
|
||||
private String publicBaseUrl;
|
||||
|
||||
private String tokenSecret;
|
||||
|
||||
private String defaultBackendNameForTables;
|
||||
private Boolean inferBackendNames = true;
|
||||
private Boolean appEnabled = true;
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
private List<QFieldMetaData> securityFields;
|
||||
private List<RecordSecurityLock> recordSecurityLocks;
|
||||
@@ -188,4 +195,80 @@ public class CrmQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether to publish the standalone CRM app.
|
||||
*******************************************************************************/
|
||||
public Boolean getAppEnabled()
|
||||
{
|
||||
return (appEnabled);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow a host to put CRM tables in its own app.
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withAppEnabled(Boolean appEnabled)
|
||||
{
|
||||
this.appEnabled = appEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tokenSecret
|
||||
*******************************************************************************/
|
||||
public String getTokenSecret()
|
||||
{
|
||||
return (tokenSecret);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure tokenSecret
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withTokenSecret(String tokenSecret)
|
||||
{
|
||||
this.tokenSecret = tokenSecret;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publicBaseUrl
|
||||
*******************************************************************************/
|
||||
public String getPublicBaseUrl()
|
||||
{
|
||||
return (publicBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure publicBaseUrl
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withPublicBaseUrl(String publicBaseUrl)
|
||||
{
|
||||
this.publicBaseUrl = publicBaseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for messagingProviderName
|
||||
*******************************************************************************/
|
||||
public String getMessagingProviderName()
|
||||
{
|
||||
return (messagingProviderName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure messagingProviderName
|
||||
*******************************************************************************/
|
||||
public CrmQBitConfig withMessagingProviderName(String messagingProviderName)
|
||||
{
|
||||
this.messagingProviderName = messagingProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
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;
|
||||
@@ -42,6 +43,8 @@ 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.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
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;
|
||||
@@ -58,7 +61,9 @@ 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.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -104,13 +109,32 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class))
|
||||
{
|
||||
widget.setName(snakeCase(widget.getName()));
|
||||
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE));
|
||||
QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker();
|
||||
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker));
|
||||
widget.setGridColumns(12);
|
||||
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
|
||||
{
|
||||
widget.withDefaultValue("joinName", snakeCase(joinName));
|
||||
String normalizedJoinName = snakeCase(joinName);
|
||||
widget.withDefaultValue("joinName", normalizedJoinName);
|
||||
QJoinMetaData join = metaDataProducerMultiOutput.getEach(QJoinMetaData.class).stream()
|
||||
.filter(candidate -> candidate.getName().equals(normalizedJoinName)).findFirst().orElseThrow();
|
||||
QTableMetaData parent = metaDataProducerMultiOutput.get(QTableMetaData.class, join.getLeftTable());
|
||||
boolean alreadyAttached = parent.getSections() != null && parent.getSections().stream()
|
||||
.anyMatch(section -> section.getWidgetName() != null && snakeCase(section.getWidgetName()).equals(widget.getName()));
|
||||
if(!alreadyAttached)
|
||||
{
|
||||
parent.withSection(new QFieldSection(widget.getName(), widget.getLabel(), new QIcon("list"), Tier.T2, List.of())
|
||||
.withWidgetName(widget.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_form_submission").setLabel("Web Leads");
|
||||
metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_email_list").setLabel("Lists");
|
||||
metaDataProducerMultiOutput.get(QTableMetaData.class, Contact.TABLE_NAME).withSection(
|
||||
new QFieldSection("crm_contact_timeline", "Timeline", new QIcon("history"), Tier.T2, List.of())
|
||||
.withWidgetName("crm_contact_timeline"));
|
||||
|
||||
for(QProcessMetaData process : metaDataProducerMultiOutput.getEach(QProcessMetaData.class))
|
||||
{
|
||||
process.setPermissionRules(CrmPermissions.processRules(process.getName()));
|
||||
@@ -234,10 +258,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
*******************************************************************************/
|
||||
public static QAppSection produceAppSection()
|
||||
{
|
||||
return new QAppSection()
|
||||
.withName("crm")
|
||||
.withLabel("CRM")
|
||||
.withIcon(new QIcon().withName("contacts"));
|
||||
return (CrmAppMetaData.mainSection());
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +314,34 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
output.add(item instanceof QWidgetMetaData widget
|
||||
? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item);
|
||||
}
|
||||
if(!Boolean.FALSE.equals(qBitConfig.getAppEnabled()))
|
||||
{
|
||||
output.add(CrmAppMetaData.build(output, getQBitMetaData().getName()));
|
||||
}
|
||||
output.add(new QAlertTypeMetaData().withName("crm.lead.received").withLabel("New web lead")
|
||||
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
|
||||
output.add(new QAlertTypeMetaData().withName("crm.sequence.failing").withLabel("Sequence needs attention")
|
||||
.withSourceQBitName(getQBitMetaData().getName()));
|
||||
output.add(new QAlertTypeMetaData().withName("crm.email.unavailable").withLabel("Email unavailable")
|
||||
.withSourceQBitName(getQBitMetaData().getName()));
|
||||
registerHooks(qInstance);
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Link the route provider only when both optional middleware classes exist.
|
||||
*******************************************************************************/
|
||||
private static void registerHooks(QInstance instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("io.javalin.Javalin");
|
||||
Class.forName("com.kingsrook.qqq.middleware.javalin.QJavalinMetaData");
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return;
|
||||
}
|
||||
com.kingsrook.qbits.crm.webhook.CrmHookRouteProvider.register(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.activities.processes.LogActivityProcess;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.MergeContactsProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess;
|
||||
import com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess;
|
||||
import com.kingsrook.qbits.crm.deals.processes.CloneDealProcess;
|
||||
import com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess;
|
||||
import com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess;
|
||||
import com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess;
|
||||
import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared streamed action lifecycle. Review never invokes a mutating operation.
|
||||
*******************************************************************************/
|
||||
public final class CrmAction
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CrmAction.class);
|
||||
|
||||
public static final String EMAIL_WARNING = "Email sending arrives with the email transport";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility holder.
|
||||
*******************************************************************************/
|
||||
private CrmAction()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin extraction to metadata and require an explicit record selection/filter.
|
||||
*******************************************************************************/
|
||||
public static class Extract extends ExtractViaQueryStep
|
||||
{
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String table = CrmActions.action(input.getProcessName()).table();
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
super.preRun(input, output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse the core selection parser for preparation and merge validation.
|
||||
*******************************************************************************/
|
||||
QQueryFilter selection(RunBackendStepInput input) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(input.getValueString("queryFilterJson"))
|
||||
&& (input.getCallback() == null || input.getCallback().getQueryFilter() == null))
|
||||
{
|
||||
throw (new QUserFacingException("Select records before running this action"));
|
||||
}
|
||||
return (super.getQueryFilter(input).clone());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (selection(input));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate read-only pipeline context and the merge survivor dropdown.
|
||||
*******************************************************************************/
|
||||
public static class Prepare implements BackendStep
|
||||
{
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
QQueryFilter filter = new Extract().selection(input);
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
List<QRecord> rows = selectedPair(action, input);
|
||||
output.addValue("mergeIds", new ArrayList<>(rows.stream().map(r -> r.getValue("id")).toList()));
|
||||
output.addValue("primaryId", rows.getFirst().getValue("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter.withLimit(2))
|
||||
.withFieldNamesToInclude(Set.of("pipelineId")).withSelectDistinct(true)).getRecords();
|
||||
List<Serializable> pipelines = rows.stream().map(r -> r.getValue("pipelineId")).distinct().toList();
|
||||
if(pipelines.size() != 1)
|
||||
{
|
||||
throw (new QUserFacingException("Select deals from one pipeline to choose a target stage"));
|
||||
}
|
||||
output.addValue("pipelineId", pipelines.getFirst());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and describe each selected row, preserving keys for standard links.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
|
||||
private final Map<String, List<Serializable>> duplicateGroups = new LinkedHashMap<>();
|
||||
private List<QRecord> pair;
|
||||
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
pair = selectedPair(action, input);
|
||||
if(pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), input.getValue("primaryId"))))
|
||||
{
|
||||
throw (new QUserFacingException("The record to keep must be one of the two selected records"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
for(QRecord extracted : input.getRecords())
|
||||
{
|
||||
Serializable id = extracted.getValue("id");
|
||||
try
|
||||
{
|
||||
QRecord row = requireRow(action, input, id);
|
||||
String reason = check(action, input, row);
|
||||
if(reason != null)
|
||||
{
|
||||
add(lines, Status.WARNING, reason, reason, id);
|
||||
continue;
|
||||
}
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
if(!Objects.equals(id, input.getValue("primaryId")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Serializable secondary = pair.stream().map(r -> r.getValue("id")).filter(key -> !key.equals(id)).findFirst().orElseThrow();
|
||||
requireRow(action, input, secondary);
|
||||
row.setValue("mergeSecondaryId", secondary);
|
||||
}
|
||||
if(action.operation().equals("duplicates"))
|
||||
{
|
||||
String key = row.getValueString(action.table().equals("crm_contact") ? "email" : "domain");
|
||||
if(StringUtils.hasContent(key))
|
||||
{
|
||||
duplicateGroups.computeIfAbsent(key.toLowerCase(Locale.ROOT), ignored -> new ArrayList<>()).add(id);
|
||||
}
|
||||
}
|
||||
String future = action.operation().equals("auto") ? "will be evaluated for assignment" : "will be " + action.past();
|
||||
add(lines, Status.OK, future, "was " + action.past(), id);
|
||||
output.addRecord(row);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
add(lines, Status.WARNING, e.getMessage(), e.getMessage(), id);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("CRM action validation failed", e);
|
||||
add(lines, Status.ERROR, "cannot be processed; validation failed (see server log)", "was not processed; validation failed (see server log)", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> summary = new ArrayList<>(lines.values());
|
||||
int group = 0;
|
||||
for(List<Serializable> ids : duplicateGroups.values())
|
||||
{
|
||||
if(ids.size() > 1)
|
||||
{
|
||||
ProcessSummaryLine line = line(Status.WARNING, "will appear in duplicate group " + (++group), "appeared in duplicate group " + group);
|
||||
ids.forEach(line::incrementCountAndAddPrimaryKey);
|
||||
summary.add(line);
|
||||
}
|
||||
}
|
||||
return (summary);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reauthorize and validate at execution time; count only actual outcomes.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> lines = new LinkedHashMap<>();
|
||||
private boolean duplicates;
|
||||
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CrmActions.Action action = CrmActions.action(input.getProcessName());
|
||||
duplicates = action.operation().equals("duplicates");
|
||||
for(QRecord reviewed : input.getRecords())
|
||||
{
|
||||
Serializable id = reviewed.getValue("id");
|
||||
try
|
||||
{
|
||||
QRecord row = requireRow(action, input, id);
|
||||
String reason = check(action, input, row);
|
||||
if(reason != null)
|
||||
{
|
||||
add(lines, Status.WARNING, reason, reason, id);
|
||||
continue;
|
||||
}
|
||||
row.setValue("mergeSecondaryId", reviewed.getValue("mergeSecondaryId"));
|
||||
String skipped = perform(action, input, row);
|
||||
if(skipped == null)
|
||||
{
|
||||
add(lines, Status.OK, "will be " + action.past(), "was " + action.past(), id);
|
||||
}
|
||||
else
|
||||
{
|
||||
add(lines, Status.WARNING, skipped, skipped, id);
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("CRM action execution failed", e);
|
||||
String reason = e instanceof QUserFacingException ? e.getMessage() : "see server log";
|
||||
add(lines, Status.ERROR, "cannot be processed: " + reason, "failed: " + reason, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> summary = new ArrayList<>(lines.values());
|
||||
if(getTransformStep() instanceof Transform transform)
|
||||
{
|
||||
if(duplicates)
|
||||
{
|
||||
return (transform.getProcessSummary(output, result));
|
||||
}
|
||||
transform.lines.values().stream().filter(line -> line.getStatus() != Status.OK).forEach(summary::add);
|
||||
}
|
||||
return (summary);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read current records under the session; read access alone cannot mutate.
|
||||
*******************************************************************************/
|
||||
private static QRecord requireRow(CrmActions.Action action, RunBackendStepInput input, Serializable id) throws QException
|
||||
{
|
||||
TablePermissionSubType permission = action.operation().equals("duplicates") ? TablePermissionSubType.READ : TablePermissionSubType.EDIT;
|
||||
if(!PermissionsHelper.hasProcessPermission(input, action.name()) || !PermissionsHelper.hasTablePermission(input, action.table(), permission))
|
||||
{
|
||||
throw (new QUserFacingException("Permission denied for this action"));
|
||||
}
|
||||
QRecord row = GetAction.execute(action.table(), id);
|
||||
if(row == null)
|
||||
{
|
||||
throw (new QUserFacingException("Record is unavailable"));
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Merge only an explicit pair, including when called outside the UI.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> selectedPair(CrmActions.Action action, RunBackendStepInput input) throws QException
|
||||
{
|
||||
QQueryFilter filter = new Extract().selection(input);
|
||||
filter.setLimit(3);
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(action.table()).withFilter(filter)).getRecords();
|
||||
if(rows.size() != 2)
|
||||
{
|
||||
throw (new QUserFacingException("Select exactly two records to merge"));
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared read-only checks for Review and Result.
|
||||
*******************************************************************************/
|
||||
private static String check(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
for(QFieldMetaData field : CrmActions.inputFields(action))
|
||||
{
|
||||
Serializable value = input.getValue(field.getName());
|
||||
if(Boolean.TRUE.equals(field.getIsRequired()) && (value == null || value instanceof String text && text.isBlank()))
|
||||
{
|
||||
return ("Supply " + field.getLabel() + " before reviewing");
|
||||
}
|
||||
if(value != null && field.getPossibleValueSourceName() != null && !field.getName().equals("primaryId"))
|
||||
{
|
||||
if(GetAction.execute(field.getPossibleValueSourceName(), value) == null)
|
||||
{
|
||||
return (field.getLabel() + " is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
switch(action.operation())
|
||||
{
|
||||
case "email":
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qbits.crm.email.CrmEmailTransport");
|
||||
return ("Email transport is present; sending integration is not configured for this action yet");
|
||||
}
|
||||
catch(ClassNotFoundException expected)
|
||||
{
|
||||
return (EMAIL_WARNING);
|
||||
}
|
||||
case "subscribe", "unsubscribe":
|
||||
if(!StringUtils.hasContent(row.getValueString("email")))
|
||||
{
|
||||
return ("Contact has no email address");
|
||||
}
|
||||
break;
|
||||
case "enroll":
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("doNotEmail")))
|
||||
{
|
||||
return ("Contact has opted out of email");
|
||||
}
|
||||
if(!Boolean.TRUE.equals(GetAction.execute("crm_email_sequence", input.getValue("sequenceId")).getValueBoolean("isActive")))
|
||||
{
|
||||
return ("Sequence is not active");
|
||||
}
|
||||
if(!enrollments(input, row).isEmpty())
|
||||
{
|
||||
return ("Contact is already enrolled in this sequence");
|
||||
}
|
||||
break;
|
||||
case "unenroll":
|
||||
if(enrollments(input, row).isEmpty())
|
||||
{
|
||||
return ("Contact has no active enrollment in this sequence");
|
||||
}
|
||||
break;
|
||||
case "stage", "won", "lost", "reopen":
|
||||
QRecord target = targetStage(action, input, row);
|
||||
if(target == null)
|
||||
{
|
||||
return ("No matching target stage exists in the deal's pipeline");
|
||||
}
|
||||
if(!Objects.equals(row.getValue("pipelineId"), target.getValue("pipelineId")))
|
||||
{
|
||||
return ("Target stage does not belong to this deal's pipeline");
|
||||
}
|
||||
if(Objects.equals(row.getValue("pipelineStageId"), target.getValue("id")))
|
||||
{
|
||||
return ("Deal is already in the target stage");
|
||||
}
|
||||
if(action.operation().equals("reopen"))
|
||||
{
|
||||
QRecord current = GetAction.execute("crm_pipeline_stage", row.getValue("pipelineStageId"));
|
||||
if(current == null || !closed(current) || closed(target))
|
||||
{
|
||||
return ("Reopen requires a closed deal and an open target stage");
|
||||
}
|
||||
}
|
||||
if(StringUtils.hasContent(target.getValueString("requiredFieldsJson")))
|
||||
{
|
||||
try
|
||||
{
|
||||
for(Object field : JsonUtils.toObject(target.getValueString("requiredFieldsJson"), List.class))
|
||||
{
|
||||
if(row.getValue(field.toString()) == null)
|
||||
{
|
||||
return ("Required field '" + field + "' must be set before moving to this stage");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw (new QException("Invalid stage required fields", e));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "createDeal":
|
||||
if(query("crm_pipeline_stage", "pipelineId", input.getValue("pipelineId")).isEmpty())
|
||||
{
|
||||
return ("The pipeline has no stages");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal stage lookup stays within each selected deal's pipeline.
|
||||
*******************************************************************************/
|
||||
private static QRecord targetStage(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
if(List.of("stage", "reopen").contains(action.operation()))
|
||||
{
|
||||
return (GetAction.execute("crm_pipeline_stage", input.getValue("targetStageId")));
|
||||
}
|
||||
List<QRecord> stages = new QueryAction().execute(new QueryInput("crm_pipeline_stage").withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, row.getValue("pipelineId")))
|
||||
.withCriteria(new QFilterCriteria(action.operation().equals("won") ? "isClosedWon" : "isClosedLost", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")).withLimit(1))).getRecords();
|
||||
return (stages.isEmpty() ? null : stages.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Closed-stage predicate.
|
||||
*******************************************************************************/
|
||||
private static boolean closed(QRecord stage)
|
||||
{
|
||||
return (Boolean.TRUE.equals(stage.getValueBoolean("isClosedWon")) || Boolean.TRUE.equals(stage.getValueBoolean("isClosedLost")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing business operations only at Load time.
|
||||
*******************************************************************************/
|
||||
private static String perform(CrmActions.Action action, RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
RunBackendStepInput call = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(new HashMap<>());
|
||||
for(QFieldMetaData field : CrmActions.inputFields(action))
|
||||
{
|
||||
call.addValue(field.getName(), input.getValue(field.getName()));
|
||||
}
|
||||
Serializable id = row.getValue("id");
|
||||
call.addValue("contactId", action.table().equals("crm_contact") ? id : input.getValue("contactId"));
|
||||
call.addValue("companyId", action.table().equals("crm_company") ? id : null);
|
||||
call.addValue("dealId", action.table().equals("crm_deal") ? id : null);
|
||||
call.addValue("formSubmissionId", id);
|
||||
RunBackendStepOutput result = new RunBackendStepOutput();
|
||||
switch(action.operation())
|
||||
{
|
||||
case "assign" -> update(action.table(), new QRecord().withValue("id", id).withValue("ownerUserId", input.getValue("newOwnerUserId")));
|
||||
case "auto" ->
|
||||
{
|
||||
call.addValue("entityType", "CONTACT");
|
||||
call.addValue("recordId", id);
|
||||
new AutoAssignProcess().run(call, result);
|
||||
if(!"ASSIGNED".equals(result.getValue("action")))
|
||||
{
|
||||
return ("No assignment rule matched; owner unchanged");
|
||||
}
|
||||
}
|
||||
case "subscribe", "unsubscribe" ->
|
||||
{
|
||||
call.addValue("email", row.getValue("email"));
|
||||
call.addValue("firstName", row.getValue("firstName"));
|
||||
call.addValue("subscriptionSource", "CRM record action");
|
||||
if(action.operation().equals("subscribe"))
|
||||
{
|
||||
new SubscribeToListProcess().run(call, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
new UnsubscribeFromListProcess().run(call, result);
|
||||
}
|
||||
if(List.of("NOOP", "NOT_FOUND").contains(result.getValue("action")))
|
||||
{
|
||||
return ("List membership unchanged");
|
||||
}
|
||||
}
|
||||
case "enroll" -> new EnrollInSequenceProcess().run(call, result);
|
||||
case "unenroll" ->
|
||||
{
|
||||
for(QRecord enrollment : enrollments(input, row))
|
||||
{
|
||||
call.addValue("enrollmentId", enrollment.getValue("id"));
|
||||
new UnenrollFromSequenceProcess().run(call, result);
|
||||
}
|
||||
}
|
||||
case "activity" -> new LogActivityProcess().run(call, result);
|
||||
case "score" -> new CalculateLeadScoresProcess().recalculate(row);
|
||||
case "merge" ->
|
||||
{
|
||||
Serializable secondary = row.getValue("mergeSecondaryId");
|
||||
List<QRecord> pair = selectedPair(action, input);
|
||||
if(secondary == null || secondary.equals(id) || pair.stream().noneMatch(r -> Objects.equals(r.getValue("id"), secondary)))
|
||||
{
|
||||
throw (new QUserFacingException("Merge selection changed; review it again"));
|
||||
}
|
||||
requireRow(action, input, secondary);
|
||||
String entity = action.table().equals("crm_contact") ? "Contact" : "Company";
|
||||
call.addValue("primary" + entity + "Id", id);
|
||||
call.addValue("secondary" + entity + "Id", secondary);
|
||||
if(entity.equals("Contact"))
|
||||
{
|
||||
new MergeContactsProcess().run(call, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
new MergeCompaniesProcess().run(call, result);
|
||||
}
|
||||
}
|
||||
case "duplicates" ->
|
||||
{
|
||||
}
|
||||
case "createDeal" -> new ConvertLeadProcess().run(call, result);
|
||||
case "erase" -> new GdprAnonymizeContactProcess().run(call, result);
|
||||
case "stage", "won", "lost", "reopen" ->
|
||||
{
|
||||
QRecord change = new QRecord().withValue("id", id).withValue("pipelineStageId", targetStage(action, input, row).getValue("id"));
|
||||
if(List.of("won", "lost").contains(action.operation()))
|
||||
{
|
||||
change.setValue("winLossReasonId", input.getValue("winLossReasonId"));
|
||||
}
|
||||
update(action.table(), change);
|
||||
}
|
||||
case "clone" -> new CloneDealProcess().run(call, result);
|
||||
case "addContact" ->
|
||||
{
|
||||
call.addValue("action", "ADD");
|
||||
new ManageContactOnDealProcess().run(call, result);
|
||||
}
|
||||
case "convert" -> new ConvertFormToContactProcess().run(call, result);
|
||||
case "activate", "pause" -> update(action.table(), new QRecord().withValue("id", id).withValue("isActive", action.operation().equals("activate")));
|
||||
default -> throw (new QUserFacingException("Action is not available"));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core DML can return record errors; never present those as successful writes.
|
||||
*******************************************************************************/
|
||||
private static void update(String table, QRecord change) throws QException
|
||||
{
|
||||
List<QRecord> records = new UpdateAction().execute(new UpdateInput(table).withRecord(change)).getRecords();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("Update was rejected: " + record.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active enrollments belonging to the selected contact and chosen sequence.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> enrollments(RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, row.getValue("id")))
|
||||
.withCriteria(new QFilterCriteria("sequenceId", QCriteriaOperator.EQUALS, input.getValue("sequenceId")))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Session-scoped related rows.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, String field, Serializable value) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)))).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard future and past messages work on both Review and Result.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
return (new ProcessSummaryLine(status).withSingularFutureMessage(future).withPluralFutureMessage(future)
|
||||
.withSingularPastMessage(past).withPluralPastMessage(past.startsWith("was ") ? "were " + past.substring(4) : past));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Group equal outcomes and retain primary keys for standard record links.
|
||||
*******************************************************************************/
|
||||
private static void add(Map<String, ProcessSummaryLine> lines, Status status, String future, String past, Serializable id)
|
||||
{
|
||||
lines.computeIfAbsent(status + ":" + future, ignored -> line(status, future, past)).incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM record actions. The installed definition owns the table and operation.
|
||||
*******************************************************************************/
|
||||
public class CrmActions implements MetaDataProducerInterface<MetaDataProducerMultiOutput>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Immutable action definition; no session state is shared between runs.
|
||||
*******************************************************************************/
|
||||
public record Action(String name, String table, String label, String operation, String past)
|
||||
{
|
||||
}
|
||||
|
||||
public static final List<Action> ACTIONS = List.of(
|
||||
new Action("crm_bulk_assign_owner", "crm_contact", "Assign Owner", "assign", "assigned a new owner"),
|
||||
new Action("crm_auto_assign", "crm_contact", "Auto-Assign", "auto", "auto-assigned"),
|
||||
new Action("crm_subscribe_to_list", "crm_contact", "Add to List", "subscribe", "added to the list"),
|
||||
new Action("crm_unsubscribe_from_list", "crm_contact", "Remove from List", "unsubscribe", "removed from the list"),
|
||||
new Action("crm_enroll_in_sequence", "crm_contact", "Enroll in Sequence", "enroll", "enrolled in the sequence"),
|
||||
new Action("crm_unenroll_from_sequence", "crm_contact", "Unenroll from Sequence", "unenroll", "unenrolled from the sequence"),
|
||||
new Action("crm_log_activity", "crm_contact", "Log Activity", "activity", "given a logged activity"),
|
||||
new Action("crm_recalculate_score", "crm_contact", "Recalculate Score", "score", "rescored"),
|
||||
new Action("crm_merge_contacts", "crm_contact", "Merge Contacts", "merge", "merged"),
|
||||
new Action("crm_detect_duplicates", "crm_contact", "Find Duplicates", "duplicates", "checked for duplicates"),
|
||||
new Action("crm_convert_lead", "crm_contact", "Create Deal", "createDeal", "given a new deal"),
|
||||
new Action("crm_gdpr_anonymize_contact", "crm_contact", "Erase Personal Data", "erase", "anonymized"),
|
||||
new Action("crm_send_email", "crm_contact", "Send Email", "email", "sent email"),
|
||||
new Action("crm_assign_company_owner", "crm_company", "Assign Owner", "assign", "assigned a new owner"),
|
||||
new Action("crm_merge_companies", "crm_company", "Merge Companies", "merge", "merged"),
|
||||
new Action("crm_find_duplicate_companies", "crm_company", "Find Duplicates", "duplicates", "checked for duplicates"),
|
||||
new Action("crm_log_company_activity", "crm_company", "Log Activity", "activity", "given a logged activity"),
|
||||
new Action("crm_move_deal_stage", "crm_deal", "Move Stage", "stage", "moved to the target stage"),
|
||||
new Action("crm_mark_deal_won", "crm_deal", "Mark Won", "won", "marked won"),
|
||||
new Action("crm_mark_deal_lost", "crm_deal", "Mark Lost", "lost", "marked lost"),
|
||||
new Action("crm_reopen_deal", "crm_deal", "Reopen", "reopen", "reopened"),
|
||||
new Action("crm_clone_deal", "crm_deal", "Clone", "clone", "cloned"),
|
||||
new Action("crm_transfer_ownership", "crm_deal", "Assign Owner", "assign", "assigned a new owner"),
|
||||
new Action("crm_manage_contact_on_deal", "crm_deal", "Add Contact", "addContact", "linked to the contact"),
|
||||
new Action("crm_log_deal_activity", "crm_deal", "Log Activity", "activity", "given a logged activity"),
|
||||
new Action("crm_convert_form_to_contact", "crm_form_submission", "Convert to Contact", "convert", "converted to contacts"),
|
||||
new Action("crm_activate_sequence", "crm_email_sequence", "Activate", "activate", "activated"),
|
||||
new Action("crm_pause_sequence", "crm_email_sequence", "Pause", "pause", "paused"));
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve exclusively by the installed process name, never client values.
|
||||
*******************************************************************************/
|
||||
public static Action action(String name)
|
||||
{
|
||||
return (ACTIONS.stream().filter(a -> a.name().equals(name)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-discovered metadata for every UI action.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance)
|
||||
{
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
ACTIONS.forEach(action -> output.add(metadata(action)));
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Input details precede the standard ETL Review and Result screens.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(Action action)
|
||||
{
|
||||
Map<String, Serializable> values = Map.of(
|
||||
StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, action.table(),
|
||||
StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, action.table(),
|
||||
StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true,
|
||||
StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true,
|
||||
StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT,
|
||||
StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, action.label() + " for the selected records.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(CrmAction.Extract.class, CrmAction.Transform.class, CrmAction.Load.class, values)
|
||||
.withName(action.name()).withLabel(action.label()).withTableName(action.table()).withMinInputRecords(1)
|
||||
.withIcon(new QIcon("play_arrow")).withPermissionRules(CrmPermissions.processRules(action.name()));
|
||||
if(action.operation().equals("merge"))
|
||||
{
|
||||
process.withMinInputRecords(2).withMaxInputRecords(2);
|
||||
}
|
||||
List<QFieldMetaData> fields = inputFields(action);
|
||||
if(!fields.isEmpty())
|
||||
{
|
||||
QFrontendStepMetaData details = new QFrontendStepMetaData().withName("details").withLabel(action.label())
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
|
||||
fields.forEach(details::withFormField);
|
||||
process.withStep(0, details);
|
||||
if(List.of("stage", "reopen", "merge").contains(action.operation()))
|
||||
{
|
||||
process.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(CrmAction.Prepare.class)));
|
||||
}
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required user input, including server-validated contingent stage choices.
|
||||
*******************************************************************************/
|
||||
public static List<QFieldMetaData> inputFields(Action action)
|
||||
{
|
||||
List<QFieldMetaData> fields = new ArrayList<>();
|
||||
switch(action.operation())
|
||||
{
|
||||
case "assign" -> fields.add(field("newOwnerUserId", "Owner", QFieldType.STRING, null));
|
||||
case "subscribe", "unsubscribe" -> fields.add(field("emailListId", "List", QFieldType.INTEGER, "crm_email_list"));
|
||||
case "enroll", "unenroll" -> fields.add(field("sequenceId", "Sequence", QFieldType.INTEGER, "crm_email_sequence"));
|
||||
case "merge" -> fields.add(field("primaryId", "Record to Keep", QFieldType.INTEGER, action.table())
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, "${input.mergeIds}"))));
|
||||
case "stage", "reopen" ->
|
||||
{
|
||||
fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline").withIsEditable(false));
|
||||
fields.add(field("targetStageId", "Target Stage", QFieldType.INTEGER, "crm_pipeline_stage")
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, "${input.pipelineId}"))));
|
||||
}
|
||||
case "won", "lost" -> fields.add(field("winLossReasonId", "Win/Loss Reason", QFieldType.INTEGER, "crm_win_loss_reason"));
|
||||
case "createDeal" ->
|
||||
{
|
||||
fields.add(field("pipelineId", "Pipeline", QFieldType.INTEGER, "crm_pipeline"));
|
||||
fields.add(field("dealName", "Deal Name", QFieldType.STRING, null));
|
||||
fields.add(field("dealAmount", "Amount", QFieldType.DECIMAL, null).withIsRequired(false));
|
||||
}
|
||||
case "addContact" ->
|
||||
{
|
||||
fields.add(field("contactId", "Contact", QFieldType.INTEGER, "crm_contact"));
|
||||
fields.add(field("contactRoleId", "Role", QFieldType.INTEGER, "crm_contact_role").withIsRequired(false));
|
||||
fields.add(field("isPrimary", "Primary Contact", QFieldType.BOOLEAN, null).withIsRequired(false));
|
||||
}
|
||||
case "activity" ->
|
||||
{
|
||||
fields.add(field("activityTypeId", "Activity Type", QFieldType.INTEGER, "crm_activity_type"));
|
||||
fields.add(field("subject", "Subject", QFieldType.STRING, null));
|
||||
fields.add(field("description", "Description", QFieldType.TEXT, null).withIsRequired(false));
|
||||
}
|
||||
default ->
|
||||
{
|
||||
}
|
||||
}
|
||||
return (fields);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consistent required field metadata.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, String label, QFieldType type, String pvs)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withLabel(label).withIsRequired(true).withPossibleValueSourceName(pvs));
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,8 @@ public class ActivityType extends QRecordEntity
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "iconName"))
|
||||
.withUniqueKey(new UniqueKey("code"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "code", "iconName"))
|
||||
.withSection(SectionFactory.defaultT2("isSystem", "sortOrder", "isActive"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
@@ -116,6 +117,9 @@ public class ActivityType extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String code;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -407,4 +411,33 @@ public class ActivityType extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for code
|
||||
*******************************************************************************/
|
||||
public String getCode()
|
||||
{
|
||||
return (this.code);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for code
|
||||
*******************************************************************************/
|
||||
public void setCode(String code)
|
||||
{
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for code
|
||||
*******************************************************************************/
|
||||
public ActivityType withCode(String code)
|
||||
{
|
||||
this.code = code;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled process that fires reminders for activities with isReminderSet
|
||||
** = true and reminderDate <= now. Clears the reminder flag after processing.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for firing reminders. Intended to run on a
|
||||
** 5-minute schedule. Finds activities with active reminders whose
|
||||
** reminderDate has passed, clears the flag, and would fire notifications
|
||||
** in a production environment.
|
||||
*******************************************************************************/
|
||||
public class FireRemindersProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_fire_reminders";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Fire Reminders")
|
||||
.withIcon(new QIcon().withName("alarm"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(FireRemindersProcess.class))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: find activities with due reminders and clear the reminder flag.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// query activities with pending reminders //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isReminderSet", QCriteriaOperator.EQUALS, true))
|
||||
.withCriteria(new QFilterCriteria("reminderDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))
|
||||
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false))));
|
||||
|
||||
int firedCount = 0;
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
/////////////////////////////////////////////////////////////
|
||||
// In production this would fire a notification. For now, //
|
||||
// just clear the reminder flag. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", record.getValueInteger("id"))
|
||||
.withValue("isReminderSet", false);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
firedCount++;
|
||||
}
|
||||
|
||||
output.addValue("firedCount", firedCount);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,13 +27,7 @@
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityOutcome;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -42,62 +36,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep that creates a crm_activity record from
|
||||
** Backend operation used by the streamed CRM actions that creates a crm_activity record from
|
||||
** the provided input fields. Sets ownerUserId from the current session.
|
||||
*******************************************************************************/
|
||||
public class LogActivityProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class LogActivityProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_log_activity";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata with input fields for all activity fields.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Log Activity")
|
||||
.withTableName(Activity.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("event_note"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(LogActivityProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("activityTypeId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(ActivityType.TABLE_NAME),
|
||||
new QFieldMetaData("subject", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("description", QFieldType.STRING),
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withPossibleValueSourceName(Contact.TABLE_NAME),
|
||||
new QFieldMetaData("companyId", QFieldType.INTEGER).withPossibleValueSourceName(Company.TABLE_NAME),
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("direction", QFieldType.INTEGER).withPossibleValueSourceName(CrmDirection.NAME),
|
||||
new QFieldMetaData("durationMinutes", QFieldType.INTEGER),
|
||||
new QFieldMetaData("outcomeId", QFieldType.INTEGER).withPossibleValueSourceName(ActivityOutcome.TABLE_NAME),
|
||||
new QFieldMetaData("location", QFieldType.STRING),
|
||||
new QFieldMetaData("conferenceLink", QFieldType.STRING),
|
||||
new QFieldMetaData("startDate", QFieldType.DATE_TIME),
|
||||
new QFieldMetaData("endDate", QFieldType.DATE_TIME)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the process: build an Activity entity from inputs and insert it.
|
||||
*******************************************************************************/
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled process that finds overdue tasks and marks them with a system
|
||||
** note. In production this would fire notifications; for now it logs a
|
||||
** message on each overdue activity.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.activities.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for processing overdue tasks. Intended to
|
||||
** run on an hourly schedule. Finds activities of type "Task" with dueDate
|
||||
** in the past and isCompleted=false.
|
||||
*******************************************************************************/
|
||||
public class ProcessOverdueTasksProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_process_overdue_tasks";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Process Overdue Tasks")
|
||||
.withIcon(new QIcon().withName("warning"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ProcessOverdueTasksProcess.class))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: find overdue tasks and mark them with a system note.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// look up the "Task" activity type //
|
||||
///////////////////////////////////////////////
|
||||
Integer taskTypeId = lookupTaskActivityTypeId();
|
||||
if(taskTypeId == null)
|
||||
{
|
||||
output.addValue("processedCount", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query overdue, incomplete tasks //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("activityTypeId", QCriteriaOperator.EQUALS, taskTypeId))
|
||||
.withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now()))
|
||||
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.EQUALS, false))));
|
||||
|
||||
int processedCount = 0;
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// In production this would create a notification. For now, add a //
|
||||
// system note to the description indicating it is overdue. //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
String currentDescription = record.getValueString("description");
|
||||
String note = "[OVERDUE] Task past due date.";
|
||||
String newDescription = currentDescription != null ? currentDescription + " " + note : note;
|
||||
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", record.getValueInteger("id"))
|
||||
.withValue("description", newDescription);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Activity.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
output.addValue("processedCount", processedCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Look up the "Task" activity type id. Returns null if not found.
|
||||
***************************************************************************/
|
||||
private Integer lookupTaskActivityTypeId() throws QException
|
||||
{
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(ActivityType.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Task"))));
|
||||
|
||||
if(!queryOutput.getRecords().isEmpty())
|
||||
{
|
||||
return (queryOutput.getRecords().get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable activity codes seeded on first use.
|
||||
*******************************************************************************/
|
||||
public enum ActivityKind
|
||||
{
|
||||
NOTE,
|
||||
EMAIL,
|
||||
CALL,
|
||||
MEETING,
|
||||
FORM_SUBMISSION,
|
||||
SEQUENCE_STEP
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.CrmQBitProducer;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM installation and email readiness for hosts and integrations.
|
||||
*******************************************************************************/
|
||||
public final class CrmApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** CRM facade cannot be instantiated.
|
||||
*******************************************************************************/
|
||||
private CrmApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the CRM metadata is installed in the supplied instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled(QInstance instance)
|
||||
{
|
||||
return (instance != null && instance.getTable("crm_contact") != null && instance.getTable("crm_consent_record") != null
|
||||
&& instance.getTable("crm_email_list_member") != null && instance.getTable("crm_form_submission") != null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report the currently configured email transport.
|
||||
*******************************************************************************/
|
||||
public static CrmReadiness readiness() throws QException
|
||||
{
|
||||
return (isInstalled(QContext.getQInstance()) ? new CrmEmailTransport(config()).readiness() : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve configuration from the current instance, never global mutable state.
|
||||
*******************************************************************************/
|
||||
public static CrmQBitConfig config() throws QUserFacingException
|
||||
{
|
||||
var bit = QContext.getQInstance().getQBits().get(CrmQBitProducer.GROUP_ID + ":" + CrmQBitProducer.ARTIFACT_ID);
|
||||
if(bit == null || !(bit.getConfig() instanceof CrmQBitConfig config))
|
||||
{
|
||||
throw (new QUserFacingException("CRM is not installed in this application."));
|
||||
}
|
||||
return (config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Website submission. visitorKey and clientHash must already be SHA-256 hashes; never pass raw identifiers.
|
||||
*******************************************************************************/
|
||||
public record CrmFormRequest(String formKey, FormType formType, String firstName, String lastName, String email, String phone, String company,
|
||||
String message, boolean marketingConsent, String consentText, String utmSource, String utmMedium, String utmCampaign,
|
||||
String utmContent, String utmTerm, String landingPage, String referrer, String visitorKey, String clientHash)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted submission and linked contact identifiers.
|
||||
*******************************************************************************/
|
||||
public record CrmFormResult(Integer submissionId, Integer contactId, boolean contactCreated)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Campaign attribution reference containing identifiers and time only.
|
||||
*******************************************************************************/
|
||||
public record CrmFormSubmissionRef(Integer submissionId, Integer contactId, Instant submittedAt)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.email.CrmEmailTransport;
|
||||
import com.kingsrook.qbits.crm.scoring.CrmAutoAssign;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaOption;
|
||||
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.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host work: the host or signed public hook validates requests before entry, so caller CRM permissions are not required.
|
||||
** Website intake, evidence-backed consent, double opt-in and signed unsubscribe operations under the current QContext.
|
||||
*******************************************************************************/
|
||||
public class CrmIntakeApi
|
||||
{
|
||||
private final CrmStore store;
|
||||
private final CrmQBitConfig config;
|
||||
private final CrmEmailTransport transport;
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the current instance configuration and standard transport.
|
||||
*******************************************************************************/
|
||||
public CrmIntakeApi() throws QException
|
||||
{
|
||||
this(CrmApi.config(), null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply a transport for host integration or deterministic delivery tests.
|
||||
*******************************************************************************/
|
||||
public CrmIntakeApi(CrmQBitConfig config, CrmEmailTransport transport)
|
||||
{
|
||||
this(config, transport, CrmStore.trusted());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve enforcement when the signed-in marketing API delegates a withdrawal.
|
||||
*******************************************************************************/
|
||||
CrmIntakeApi(CrmQBitConfig config, CrmEmailTransport transport, CrmStore store)
|
||||
{
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
this.transport = transport == null ? new CrmEmailTransport(config) : transport;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Store a form and attribution, recording marketing consent only for explicit opt-in.
|
||||
*******************************************************************************/
|
||||
public CrmFormResult submitForm(CrmFormRequest request) throws QException
|
||||
{
|
||||
validate(request);
|
||||
CrmStarterData.ensure(store);
|
||||
List<QRecord> contacts = contacts(request.email());
|
||||
boolean created = contacts.isEmpty();
|
||||
QRecord contact = contact(request, contacts);
|
||||
Integer id = contact.getValueInteger("id");
|
||||
QRecord submission = submission(request, id);
|
||||
consent(id, request.marketingConsent() ? CrmConsentType.EMAIL : CrmConsentType.REPLY_TO_INQUIRY, CrmConsentStatus.GRANTED,
|
||||
request.marketingConsent() ? CrmLegalBasis.CONSENT : CrmLegalBasis.LEGITIMATE_INTEREST,
|
||||
request.formKey(), request.consentText(), submission.getValueInteger("id"));
|
||||
store.recordActivity(id, ActivityKind.FORM_SUBMISSION, "Form submission", request.message());
|
||||
notifyLead(submission, NotificationAudience.permission("crm.read"));
|
||||
if(!isUnassigned(contact.getValueString("ownerUserId")))
|
||||
{
|
||||
notifyLead(submission, NotificationAudience.user(contact.getValueString("ownerUserId")));
|
||||
}
|
||||
return (new CrmFormResult(submission.getValueInteger("id"), id, created));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Subscribe to an active list. Pending confirmation never grants Email consent.
|
||||
*******************************************************************************/
|
||||
public CrmSubscribeResult subscribe(CrmSubscribeRequest request) throws QException
|
||||
{
|
||||
if(request == null || request.listKey() == null || request.listKey().isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("A mailing list key is required."));
|
||||
}
|
||||
List<QRecord> lists = store.by("crm_email_list", "listKey", request.listKey());
|
||||
if(lists.isEmpty() || !Boolean.TRUE.equals(lists.get(0).getValueBoolean("isActive")))
|
||||
{
|
||||
throw (new QUserFacingException("This mailing list is unavailable."));
|
||||
}
|
||||
QRecord list = lists.get(0);
|
||||
Integer listId = list.getValueInteger("id");
|
||||
String wording = request.consentText() == null || request.consentText().isBlank() ? list.getValueString("consentText") : request.consentText();
|
||||
CrmFormRequest form = new CrmFormRequest(request.listKey(), FormType.NEWSLETTER, request.firstName(), request.lastName(),
|
||||
request.email(), null, null, null, true, wording, request.utmSource(), request.utmMedium(), request.utmCampaign(),
|
||||
request.utmContent(), request.utmTerm(), null, null, request.visitorKey(), request.clientHash());
|
||||
validate(form);
|
||||
List<QRecord> contacts = contacts(request.email());
|
||||
QRecord member = null;
|
||||
if(!contacts.isEmpty())
|
||||
{
|
||||
List<QRecord> members = store.query("crm_email_list_member", new QQueryFilter(
|
||||
new QFilterCriteria("emailListId", QCriteriaOperator.EQUALS, listId),
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contacts.get(0).getValueInteger("id"))));
|
||||
member = members.isEmpty() ? null : members.get(0);
|
||||
if(member != null && CrmListMemberStatus.SUBSCRIBED.getId().equals(member.getValueInteger("status")))
|
||||
{
|
||||
return (new CrmSubscribeResult(SubscriptionStatus.ALREADY_SUBSCRIBED));
|
||||
}
|
||||
}
|
||||
boolean doubleOptIn = !Boolean.FALSE.equals(list.getValueBoolean("requireDoubleOptIn"));
|
||||
CrmTokens tokens = doubleOptIn ? new CrmTokens(config) : null;
|
||||
if(doubleOptIn)
|
||||
{
|
||||
tokens.url("confirm", "configuration-check");
|
||||
if(transport.readiness() == CrmReadiness.NOT_CONFIGURED)
|
||||
{
|
||||
throw (new QUserFacingException("CRM email is not configured for list confirmation."));
|
||||
}
|
||||
}
|
||||
CrmStarterData.ensure(store);
|
||||
QRecord contact = contact(form, contacts);
|
||||
Integer contactId = contact.getValueInteger("id");
|
||||
QRecord submission = submission(form, contactId);
|
||||
QRecord values = new QRecord().withValue("emailListId", listId).withValue("contactId", contactId)
|
||||
.withValue("email", normalize(request.email())).withValue("firstName", request.firstName())
|
||||
.withValue("status", (doubleOptIn ? CrmListMemberStatus.PENDING_CONFIRMATION : CrmListMemberStatus.SUBSCRIBED).getId()).withValue("source", request.source()).withValue("consentText", wording)
|
||||
.withValue("formSubmissionId", submission.getValueInteger("id")).withValue("unsubscribedDate", null)
|
||||
.withValue("subscribedDate", doubleOptIn ? null : Instant.now()).withValue("confirmedDate", null);
|
||||
if(member == null)
|
||||
{
|
||||
member = store.insert("crm_email_list_member", values);
|
||||
}
|
||||
else
|
||||
{
|
||||
member = store.update("crm_email_list_member", values.withValue("id", member.getValueInteger("id")));
|
||||
}
|
||||
Integer memberId = member.getValueInteger("id");
|
||||
if(doubleOptIn)
|
||||
{
|
||||
String token = tokens.issue("confirm", memberId, listId);
|
||||
String hash = CrmTokens.hash(token);
|
||||
store.update("crm_email_list_member", new QRecord().withValue("id", memberId)
|
||||
.withValue("confirmationTokenHash", hash).withValue("confirmationSentDate", Instant.now()));
|
||||
transport.send("confirm", memberId + ":" + hash, contactId, normalize(request.email()), "Confirm your subscription",
|
||||
"Please confirm your subscription: " + tokens.url("confirm", token), true);
|
||||
return (new CrmSubscribeResult(SubscriptionStatus.PENDING_CONFIRMATION));
|
||||
}
|
||||
consent(contactId, CrmConsentType.EMAIL, CrmConsentStatus.GRANTED, CrmLegalBasis.CONSENT, request.source(), wording, submission.getValueInteger("id"));
|
||||
store.update("crm_contact", new QRecord().withValue("id", contactId).withValue("unsubscribeDate", null));
|
||||
return (new CrmSubscribeResult(SubscriptionStatus.SUBSCRIBED));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Confirm the current token only. Repeated confirmation is harmless; refreshed and withdrawn tokens fail.
|
||||
*******************************************************************************/
|
||||
public void confirm(String token) throws QException
|
||||
{
|
||||
CrmTokens.Claims claims = new CrmTokens(config).verify(token, "confirm");
|
||||
QRecord member = store.get("crm_email_list_member", claims.id());
|
||||
if(!Objects.equals(claims.listId(), member.getValueInteger("emailListId"))
|
||||
|| !Objects.equals(CrmTokens.hash(token), member.getValueString("confirmationTokenHash")))
|
||||
{
|
||||
throw (new QUserFacingException("This confirmation link is no longer available. Please subscribe again."));
|
||||
}
|
||||
if(CrmListMemberStatus.SUBSCRIBED.getId().equals(member.getValueInteger("status")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!CrmListMemberStatus.PENDING_CONFIRMATION.getId().equals(member.getValueInteger("status"))
|
||||
|| !Boolean.TRUE.equals(store.get("crm_email_list", claims.listId()).getValueBoolean("isActive")))
|
||||
{
|
||||
throw (new QUserFacingException("This subscription cannot be confirmed."));
|
||||
}
|
||||
store.get("crm_contact", member.getValueInteger("contactId"));
|
||||
consent(member.getValueInteger("contactId"), CrmConsentType.EMAIL, CrmConsentStatus.GRANTED, CrmLegalBasis.CONSENT, member.getValueString("source"), member.getValueString("consentText"), member.getValueInteger("formSubmissionId"));
|
||||
store.update("crm_contact", new QRecord().withValue("id", member.getValueInteger("contactId")).withValue("unsubscribeDate", null));
|
||||
store.update("crm_email_list_member", new QRecord().withValue("id", claims.id()).withValue("status", CrmListMemberStatus.SUBSCRIBED.getId())
|
||||
.withValue("confirmedDate", Instant.now()).withValue("subscribedDate", Instant.now()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply a signed list-level or contact-level unsubscribe.
|
||||
*******************************************************************************/
|
||||
public void unsubscribe(String token) throws QException
|
||||
{
|
||||
CrmTokens.Claims claims = new CrmTokens(config).verify(token, "unsubscribe");
|
||||
unsubscribeByContact(claims.id(), claims.listId(), "Signed unsubscribe link");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Withdraw consent and mark the selected memberships. Null listId also suppresses the contact globally.
|
||||
*******************************************************************************/
|
||||
public void unsubscribeByContact(Integer contactId, Integer listId, String source) throws QException
|
||||
{
|
||||
store.get("crm_contact", contactId);
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId));
|
||||
if(listId != null)
|
||||
{
|
||||
store.get("crm_email_list", listId);
|
||||
filter.addCriteria(new QFilterCriteria("emailListId", QCriteriaOperator.EQUALS, listId));
|
||||
}
|
||||
for(QRecord member : store.query("crm_email_list_member", filter))
|
||||
{
|
||||
store.update("crm_email_list_member", new QRecord().withValue("id", member.getValueInteger("id"))
|
||||
.withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()).withValue("unsubscribedDate", Instant.now()).withValue("confirmationTokenHash", null));
|
||||
}
|
||||
if(listId == null)
|
||||
{
|
||||
store.update("crm_contact", new QRecord().withValue("id", contactId).withValue("unsubscribeDate", Instant.now()));
|
||||
}
|
||||
consent(contactId, CrmConsentType.EMAIL, CrmConsentStatus.WITHDRAWN, CrmLegalBasis.CONSENT, source, "Email consent withdrawn", null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate before any writes, including the caller's pre-hashed pseudonymous identifiers.
|
||||
*******************************************************************************/
|
||||
private void validate(CrmFormRequest request) throws QUserFacingException
|
||||
{
|
||||
if(request == null || request.formKey() == null || request.formKey().isBlank() || request.formType() == null
|
||||
|| request.email() == null || !normalize(request.email()).matches("[^@\\s]+@[^@\\s]+"))
|
||||
{
|
||||
throw (new QUserFacingException("A form key, form type and valid email address are required."));
|
||||
}
|
||||
if(request.marketingConsent() && (request.consentText() == null || request.consentText().isBlank()))
|
||||
{
|
||||
throw (new QUserFacingException("Marketing consent requires the exact wording shown to the visitor."));
|
||||
}
|
||||
for(String hash : new String[] {request.visitorKey(), request.clientHash()})
|
||||
{
|
||||
if(hash != null && !hash.matches("[a-fA-F0-9]{64}"))
|
||||
{
|
||||
throw (new QUserFacingException("Visitor and client identifiers must be SHA-256 hashes, never raw identifiers."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve email without case sensitivity using the selected storage mode.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> contacts(String email) throws QException
|
||||
{
|
||||
return (store.query("crm_contact", new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, normalize(email))
|
||||
.withOption(CriteriaOption.CASE_INSENSITIVE))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a lead if needed, then preserve the first touch and refresh last-touch attribution only when supplied.
|
||||
*******************************************************************************/
|
||||
private QRecord contact(CrmFormRequest request, List<QRecord> contacts) throws QException
|
||||
{
|
||||
QRecord contact;
|
||||
if(contacts.isEmpty())
|
||||
{
|
||||
String source = request.utmSource() == null || request.utmSource().isBlank() ? "Website" : request.utmSource();
|
||||
List<QRecord> sources = store.by("crm_lead_source", "name", source);
|
||||
QRecord leadSource = sources.isEmpty() ? store.insert("crm_lead_source", new QRecord().withValue("name", source).withValue("sortOrder", 0)) : sources.get(0);
|
||||
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
|
||||
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
|
||||
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
|
||||
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
|
||||
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
|
||||
}
|
||||
else
|
||||
{
|
||||
contact = contacts.get(0);
|
||||
}
|
||||
QRecord update = new QRecord().withValue("id", contact.getValueInteger("id"));
|
||||
String[] suffixes = {"UtmSource", "UtmMedium", "UtmCampaign", "UtmContent", "UtmTerm", "LandingPage", "Referrer"};
|
||||
String[] values = {request.utmSource(), request.utmMedium(), request.utmCampaign(), request.utmContent(), request.utmTerm(), request.landingPage(), request.referrer()};
|
||||
boolean attributed = Arrays.stream(values, 0, 6).anyMatch(value -> value != null && !value.isBlank());
|
||||
boolean first = contact.getValue("firstTouchDate") == null;
|
||||
for(int i = 0; i < suffixes.length; i++)
|
||||
{
|
||||
if(first && contact.getValue("firstTouch" + suffixes[i]) == null)
|
||||
{
|
||||
update.withValue("firstTouch" + suffixes[i], values[i]);
|
||||
}
|
||||
if(attributed)
|
||||
{
|
||||
update.withValue("lastTouch" + suffixes[i], values[i]);
|
||||
}
|
||||
}
|
||||
if(first)
|
||||
{
|
||||
update.withValue("firstTouchDate", Instant.now());
|
||||
}
|
||||
update.withValue("lastTouchDate", Instant.now());
|
||||
store.update("crm_contact", update);
|
||||
if(contacts.isEmpty() && isUnassigned(contact.getValueString("ownerUserId")))
|
||||
{
|
||||
CrmAutoAssign.assign(CrmAssignableEntity.CONTACT, contact.getValueInteger("id"));
|
||||
}
|
||||
return (store.get("crm_contact", contact.getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist exact submission evidence without raw network identifiers.
|
||||
*******************************************************************************/
|
||||
private QRecord submission(CrmFormRequest request, Integer contactId) throws QException
|
||||
{
|
||||
CrmFormType formType;
|
||||
switch(request.formType())
|
||||
{
|
||||
case CONTACT -> formType = CrmFormType.CONTACT;
|
||||
case DEMO -> formType = CrmFormType.DEMO_REQUEST;
|
||||
case NEWSLETTER -> formType = CrmFormType.NEWSLETTER;
|
||||
default -> formType = CrmFormType.GENERAL;
|
||||
}
|
||||
return (store.insert("crm_form_submission", new QRecord().withValue("formKey", request.formKey()).withValue("formType", formType.getId())
|
||||
.withValue("email", normalize(request.email())).withValue("firstName", request.firstName()).withValue("lastName", request.lastName())
|
||||
.withValue("phone", request.phone()).withValue("company", request.company()).withValue("message", request.message())
|
||||
.withValue("marketingConsent", request.marketingConsent()).withValue("consentText", request.consentText())
|
||||
.withValue("utmSource", request.utmSource()).withValue("utmMedium", request.utmMedium()).withValue("utmCampaign", request.utmCampaign())
|
||||
.withValue("utmContent", request.utmContent()).withValue("utmTerm", request.utmTerm()).withValue("landingPage", request.landingPage())
|
||||
.withValue("referrer", request.referrer()).withValue("visitorKey", request.visitorKey()).withValue("clientHash", request.clientHash())
|
||||
.withValue("contactId", contactId).withValue("conversionStatus", CrmFormConversionStatus.CONVERTED_TO_CONTACT.getId()).withValue("convertedDate", Instant.now())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Append consent evidence. Existing records remain immutable.
|
||||
*******************************************************************************/
|
||||
private void consent(Integer contactId, CrmConsentType type, CrmConsentStatus status, CrmLegalBasis basis, String source, String wording, Integer submissionId) throws QException
|
||||
{
|
||||
store.insert("crm_consent_record", new QRecord().withValue("contactId", contactId).withValue("consentType", type.getId())
|
||||
.withValue("status", status.getId()).withValue("legalBasis", basis.getId()).withValue("source", source).withValue("consentText", wording)
|
||||
.withValue("evidenceFormSubmissionId", submissionId).withValue("consentDate", Instant.now()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core signals accept one audience; publish the same lead to each intended audience.
|
||||
*******************************************************************************/
|
||||
private void notifyLead(QRecord submission, NotificationAudience audience)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("New web lead received").withSourceName("crm.lead.received")
|
||||
.withSourceKey("crm.lead.received:" + submission.getValueInteger("id"))
|
||||
.withSourceTableName("crm_form_submission").withSourceRecordId(submission.getValueString("id"))
|
||||
.withAudience(audience));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** System and anonymous placeholders do not represent a sales owner.
|
||||
*******************************************************************************/
|
||||
private boolean isUnassigned(String owner)
|
||||
{
|
||||
return (owner == null || owner.isBlank() || "system".equalsIgnoreCase(owner) || "anonymous".equalsIgnoreCase(owner));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize email for new writes.
|
||||
*******************************************************************************/
|
||||
private String normalize(String email)
|
||||
{
|
||||
return (email.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing CRM metadata requires both name fields; retain an honest missing-name label.
|
||||
*******************************************************************************/
|
||||
private String name(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? "Not provided" : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
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.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforces caller permissions because marketing runs for a signed-in user or worker.
|
||||
** Supported marketing contract. Only eligible recipients expose addresses; all other responses contain identifiers or reason codes.
|
||||
*******************************************************************************/
|
||||
public class CrmMarketingApi
|
||||
{
|
||||
private final CrmStore store = CrmStore.asUser();
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current application contains the CRM contract tables.
|
||||
*******************************************************************************/
|
||||
public boolean isAvailable()
|
||||
{
|
||||
return (CrmApi.isInstalled(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve crm-list:id or crm-contact:id with fresh suppression checks.
|
||||
*******************************************************************************/
|
||||
public List<CrmRecipient> resolveRecipients(String scope) throws QException
|
||||
{
|
||||
List<CrmRecipient> result = new ArrayList<>();
|
||||
if(scope == null)
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
String[] parts = scope.split(":", -1);
|
||||
if(parts.length != 2 || !("crm-list".equals(parts[0]) || "crm-contact".equals(parts[0])))
|
||||
{
|
||||
throw (new QUserFacingException("Use a CRM recipient scope: crm-list:<id> or crm-contact:<id>."));
|
||||
}
|
||||
Integer id;
|
||||
try
|
||||
{
|
||||
id = Integer.valueOf(parts[1]);
|
||||
if(id <= 0)
|
||||
{
|
||||
throw (new NumberFormatException());
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QUserFacingException("A CRM recipient scope must end in a positive numeric identifier."));
|
||||
}
|
||||
if("crm-contact".equals(parts[0]))
|
||||
{
|
||||
result.add(recipient(id, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord list = store.get("crm_email_list", id);
|
||||
for(QRecord member : store.by("crm_email_list_member", "emailListId", id))
|
||||
{
|
||||
Integer contactId = member.getValueInteger("contactId");
|
||||
if(contactId != null)
|
||||
{
|
||||
String reason = !Boolean.TRUE.equals(list.getValueBoolean("isActive")) ? "list_inactive"
|
||||
: CrmListMemberStatus.SUBSCRIBED.getId().equals(member.getValueInteger("status")) ? null : "list_member_not_subscribed";
|
||||
result.add(recipient(contactId, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest Email consent decides; suppression always wins. No consent means no marketing email.
|
||||
*******************************************************************************/
|
||||
public Eligibility checkEmailEligibility(Integer contactId) throws QException
|
||||
{
|
||||
List<QRecord> contacts = contactId == null ? List.of() : store.by("crm_contact", "id", contactId);
|
||||
if(contacts.isEmpty())
|
||||
{
|
||||
return (no("contact_not_found"));
|
||||
}
|
||||
QRecord contact = contacts.get(0);
|
||||
if(Boolean.TRUE.equals(contact.getValueBoolean("doNotEmail")))
|
||||
{
|
||||
return (no("do_not_email"));
|
||||
}
|
||||
if(contact.getValue("unsubscribeDate") != null)
|
||||
{
|
||||
return (no("unsubscribed"));
|
||||
}
|
||||
if(contact.getValueString("email") == null || contact.getValueString("email").isBlank())
|
||||
{
|
||||
return (no("no_email_address"));
|
||||
}
|
||||
List<QRecord> consents = store.query("crm_consent_record", new QQueryFilter(
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId), new QFilterCriteria("consentType", QCriteriaOperator.EQUALS, CrmConsentType.EMAIL.getId())));
|
||||
if(consents.isEmpty())
|
||||
{
|
||||
return (no("no_email_consent"));
|
||||
}
|
||||
QRecord latest = consents.stream().max(Comparator.comparing((QRecord row) -> row.getValueInstant("consentDate") == null
|
||||
? Instant.EPOCH : row.getValueInstant("consentDate")).thenComparing(row -> row.getValueInteger("id"))).orElseThrow();
|
||||
if(!CrmConsentStatus.GRANTED.getId().equals(latest.getValueInteger("status")))
|
||||
{
|
||||
return (no("email_consent_withdrawn"));
|
||||
}
|
||||
Instant expiry = latest.getValueInstant("expiryDate");
|
||||
if(expiry != null && !expiry.isAfter(Instant.now()))
|
||||
{
|
||||
return (no("email_consent_expired"));
|
||||
}
|
||||
return (new Eligibility(true, "eligible"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Campaign references contain no form contents or addresses.
|
||||
*******************************************************************************/
|
||||
public List<CrmFormSubmissionRef> listFormSubmissions(String utmCampaign, Instant since) throws QException
|
||||
{
|
||||
if(utmCampaign == null || utmCampaign.isBlank())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("utmCampaign", QCriteriaOperator.EQUALS, utmCampaign));
|
||||
if(since != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since));
|
||||
}
|
||||
return (store.query("crm_form_submission", filter).stream().map(row -> new CrmFormSubmissionRef(row.getValueInteger("id"),
|
||||
row.getValueInteger("contactId"), row.getValueInstant("createDate"))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Log a typed interaction through the normal activity table actions.
|
||||
*******************************************************************************/
|
||||
public void recordActivity(Integer contactId, ActivityKind kind, String subject, String description) throws QException
|
||||
{
|
||||
store.recordActivity(contactId, kind, subject, description);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Convenience overload for the existing marketing gateway's Note activity contract.
|
||||
*******************************************************************************/
|
||||
public void recordActivity(Integer contactId, String subject, String description) throws QException
|
||||
{
|
||||
recordActivity(contactId, ActivityKind.NOTE, subject, description);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record an integration-originated withdrawal of email consent.
|
||||
*******************************************************************************/
|
||||
public void recordUnsubscribe(Integer contactId, Integer listId, String source) throws QException
|
||||
{
|
||||
new CrmIntakeApi(CrmApi.config(), null, store).unsubscribeByContact(contactId, listId, source);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a ninety-day signed link without returning any personal data.
|
||||
*******************************************************************************/
|
||||
public String buildUnsubscribeUrl(Integer contactId, Integer listId) throws QException
|
||||
{
|
||||
store.get("crm_contact", contactId);
|
||||
if(listId != null)
|
||||
{
|
||||
store.get("crm_email_list", listId);
|
||||
}
|
||||
CrmTokens tokens = new CrmTokens(CrmApi.config());
|
||||
return (tokens.url("unsubscribe", tokens.issue("unsubscribe", contactId, listId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Offer only active lists visible to this caller.
|
||||
*******************************************************************************/
|
||||
public List<RecipientScopeOption> recipientScopeOptions() throws QException
|
||||
{
|
||||
return (store.by("crm_email_list", "isActive", true).stream()
|
||||
.map(row -> new RecipientScopeOption("crm-list:" + row.getValueInteger("id"), row.getValueString("name"))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch an address only after eligibility is established.
|
||||
*******************************************************************************/
|
||||
private CrmRecipient recipient(Integer contactId, String reason) throws QException
|
||||
{
|
||||
Eligibility eligibility = reason == null ? checkEmailEligibility(contactId) : no(reason);
|
||||
return (new CrmRecipient(contactId, eligibility.eligible() ? store.get("crm_contact", contactId).getValueString("email") : null, eligibility));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a suppression result without personal data.
|
||||
*******************************************************************************/
|
||||
private Eligibility no(String reason)
|
||||
{
|
||||
return (new Eligibility(false, reason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Available email delivery path; configuration is not a delivery guarantee.
|
||||
*******************************************************************************/
|
||||
public enum CrmReadiness
|
||||
{
|
||||
NOT_CONFIGURED,
|
||||
QBIT_EMAIL,
|
||||
CORE_MESSAGING
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient address is present only when marketing email is eligible.
|
||||
*******************************************************************************/
|
||||
public record CrmRecipient(Integer contactId, String address, Eligibility eligibility)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable recipient key for marketing executions; never use an address as an id.
|
||||
*******************************************************************************/
|
||||
public String key()
|
||||
{
|
||||
return ("crm-contact:" + contactId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent activity-code initialization through normal table actions, never producer side effects.
|
||||
*******************************************************************************/
|
||||
public final class CrmStarterData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No instances.
|
||||
*******************************************************************************/
|
||||
private CrmStarterData()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Find or insert each supported activity code in the current instance.
|
||||
*******************************************************************************/
|
||||
public static void ensure() throws QException
|
||||
{
|
||||
ensure(CrmStore.asUser());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Initialize codes using the initiating operation's permission mode.
|
||||
*******************************************************************************/
|
||||
static void ensure(CrmStore store) throws QException
|
||||
{
|
||||
for(ActivityKind kind : ActivityKind.values())
|
||||
{
|
||||
if(!store.by("crm_activity_type", "code", kind.name()).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String label = kind.name().substring(0, 1) + kind.name().substring(1).toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
List<QRecord> named = store.by("crm_activity_type", "name", label);
|
||||
if(!named.isEmpty() && named.get(0).getValue("code") == null)
|
||||
{
|
||||
store.update("crm_activity_type", new QRecord().withValue("id", named.get(0).getValue("id")).withValue("code", kind.name()));
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
store.insert("crm_activity_type", new QRecord().withValue("name", label).withValue("code", kind.name())
|
||||
.withValue("sortOrder", kind.ordinal()).withValue("isSystem", true));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(store.by("crm_activity_type", "code", kind.name()).isEmpty())
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal table-action boundary. Explicitly selects trusted host work or caller permission enforcement.
|
||||
*******************************************************************************/
|
||||
final class CrmStore
|
||||
{
|
||||
private final PermissionEnforcement permissionEnforcement;
|
||||
|
||||
/*******************************************************************************
|
||||
** Select permission enforcement for all actions in this store.
|
||||
*******************************************************************************/
|
||||
private CrmStore(PermissionEnforcement permissionEnforcement)
|
||||
{
|
||||
this.permissionEnforcement = permissionEnforcement;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validated host intake is internal work, independent of the visitor's permissions.
|
||||
*******************************************************************************/
|
||||
static CrmStore trusted()
|
||||
{
|
||||
return (new CrmStore(PermissionEnforcement.SKIP));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed-in users and marketing workers must hold the permissions for their work.
|
||||
*******************************************************************************/
|
||||
static CrmStore asUser()
|
||||
{
|
||||
return (new CrmStore(PermissionEnforcement.ENFORCE));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core requires an explicit reason when selecting SKIP for internal work.
|
||||
*******************************************************************************/
|
||||
private void applyPermissions(AbstractActionInput input)
|
||||
{
|
||||
if(permissionEnforcement == PermissionEnforcement.SKIP)
|
||||
{
|
||||
input.withPermissionBypass("CRM intake is trusted host work after public request validation");
|
||||
}
|
||||
else
|
||||
{
|
||||
input.withPermissionEnforcement(permissionEnforcement);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query through core actions.
|
||||
*******************************************************************************/
|
||||
List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter);
|
||||
applyPermissions(input);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query a single field.
|
||||
*******************************************************************************/
|
||||
List<QRecord> by(String table, String field, Serializable value) throws QException
|
||||
{
|
||||
return (query(table, new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retrieve a visible record or fail without disclosing its contents.
|
||||
*******************************************************************************/
|
||||
QRecord get(String table, Integer id) throws QException
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
throw (new QUserFacingException("A record identifier is required."));
|
||||
}
|
||||
List<QRecord> rows = by(table, "id", id);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("The CRM record is unavailable."));
|
||||
}
|
||||
return (rows.get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert and surface record-level validation failures.
|
||||
*******************************************************************************/
|
||||
QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(table).withRecord(record);
|
||||
applyPermissions(input);
|
||||
return (checked(new InsertAction().execute(input).getRecords()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Update without bypassing customizers, audit or locks.
|
||||
*******************************************************************************/
|
||||
QRecord update(String table, QRecord record) throws QException
|
||||
{
|
||||
UpdateInput input = new UpdateInput(table).withRecords(List.of(record));
|
||||
applyPermissions(input);
|
||||
return (checked(new UpdateAction().execute(input).getRecords()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record an interaction with the same permission mode as the initiating operation.
|
||||
*******************************************************************************/
|
||||
void recordActivity(Integer contactId, ActivityKind kind, String subject, String description) throws QException
|
||||
{
|
||||
get("crm_contact", contactId);
|
||||
if(kind == null || subject == null || subject.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("An activity kind and subject are required."));
|
||||
}
|
||||
CrmStarterData.ensure(this);
|
||||
Integer typeId = by("crm_activity_type", "code", kind.name()).get(0).getValueInteger("id");
|
||||
insert("crm_activity", new QRecord().withValue("contactId", contactId).withValue("activityTypeId", typeId)
|
||||
.withValue("subject", subject).withValue("description", description).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
|
||||
.withValue("isCompleted", true).withValue("completedDate", Instant.now()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never return success when the action refused a record.
|
||||
*******************************************************************************/
|
||||
private static QRecord checked(List<QRecord> records) throws QException
|
||||
{
|
||||
if(records.isEmpty() || (records.get(0).getErrors() != null && !records.get(0).getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QUserFacingException("CRM could not save the record. Check required fields and permissions."));
|
||||
}
|
||||
return (records.get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit mailing-list signup and the exact wording accepted by the visitor.
|
||||
*******************************************************************************/
|
||||
public record CrmSubscribeRequest(String listKey, String email, String firstName, String lastName, String consentText, String source,
|
||||
String utmSource, String utmMedium, String utmCampaign, String utmContent, String utmTerm, String visitorKey, String clientHash)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of a mailing-list signup.
|
||||
*******************************************************************************/
|
||||
public record CrmSubscribeResult(SubscriptionStatus status)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email eligibility with a stable reason code and no personal data.
|
||||
*******************************************************************************/
|
||||
public record Eligibility(boolean eligible, String reason)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported intake form kinds.
|
||||
*******************************************************************************/
|
||||
public enum FormType
|
||||
{
|
||||
CONTACT,
|
||||
NEWSLETTER,
|
||||
DEMO,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An active list offered as a marketing recipient scope.
|
||||
*******************************************************************************/
|
||||
public record RecipientScopeOption(String scope, String label)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mailing-list subscription outcomes.
|
||||
*******************************************************************************/
|
||||
public enum SubscriptionStatus
|
||||
{
|
||||
PENDING_CONFIRMATION,
|
||||
SUBSCRIBED,
|
||||
ALREADY_SUBSCRIBED
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -63,6 +64,9 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = Company.TableMetaDataCustomizer.class,
|
||||
childTables = {
|
||||
@ChildTable(childTableEntityClass = Activity.class, joinFieldName = "companyId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = Contact.class, joinFieldName = "companyId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Contacts", enabled = true, maxRows = 50)),
|
||||
|
||||
@@ -31,6 +31,8 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
@@ -81,7 +83,7 @@ public class ConsentRecord extends QRecordEntity
|
||||
.withRecordLabelFormat("Consent %s - %s")
|
||||
.withRecordLabelFields("contactId", "consentType")
|
||||
.withSection(SectionFactory.defaultT1("id", "contactId", "consentType", "status"))
|
||||
.withSection(SectionFactory.defaultT2("legalBasis", "source", "consentDate", "expiryDate", "ipAddress"))
|
||||
.withSection(SectionFactory.defaultT2("legalBasis", "source", "consentDate", "expiryDate", "consentText", "evidenceFormSubmissionId"))
|
||||
.withSection(SectionFactory.defaultT3("createDate"));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
@@ -90,6 +92,8 @@ public class ConsentRecord extends QRecordEntity
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(PreventUpdateCustomizer.class));
|
||||
table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(PreventDeleteCustomizer.class));
|
||||
|
||||
table.getField("consentText").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
@@ -130,6 +134,12 @@ public class ConsentRecord extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(possibleValueSourceName = FormSubmission.TABLE_NAME)
|
||||
private Integer evidenceFormSubmissionId;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -154,9 +164,6 @@ public class ConsentRecord extends QRecordEntity
|
||||
@QField()
|
||||
private Instant expiryDate;
|
||||
|
||||
@QField(maxLength = 45, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String ipAddress;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -429,37 +436,6 @@ public class ConsentRecord extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ipAddress
|
||||
*******************************************************************************/
|
||||
public String getIpAddress()
|
||||
{
|
||||
return (this.ipAddress);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public void setIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public ConsentRecord withIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
@@ -489,4 +465,61 @@ public class ConsentRecord extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public ConsentRecord withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for evidenceFormSubmissionId
|
||||
*******************************************************************************/
|
||||
public Integer getEvidenceFormSubmissionId()
|
||||
{
|
||||
return (this.evidenceFormSubmissionId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for evidenceFormSubmissionId
|
||||
*******************************************************************************/
|
||||
public void setEvidenceFormSubmissionId(Integer evidenceFormSubmissionId)
|
||||
{
|
||||
this.evidenceFormSubmissionId = evidenceFormSubmissionId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for evidenceFormSubmissionId
|
||||
*******************************************************************************/
|
||||
public ConsentRecord withEvidenceFormSubmissionId(Integer evidenceFormSubmissionId)
|
||||
{
|
||||
this.evidenceFormSubmissionId = evidenceFormSubmissionId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,7 +30,9 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealContact;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailMessage;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -62,12 +64,24 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = Contact.TableMetaDataCustomizer.class,
|
||||
childTables = {
|
||||
@ChildTable(childTableEntityClass = Deal.class, joinFieldName = "primaryContactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = EmailListMember.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "List Memberships", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = SequenceEnrollment.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Enrollments", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = EmailMessage.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Email Messages", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = FormSubmission.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Web Leads", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = Activity.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 50)),
|
||||
@ChildTable(childTableEntityClass = DealContact.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)),
|
||||
@ChildTable(childTableEntityClass = ConsentRecord.class, joinFieldName = "contactId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Consent Records", enabled = true, maxRows = 25))
|
||||
@@ -107,6 +121,8 @@ public class Contact extends QRecordEntity
|
||||
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
|
||||
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
|
||||
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
|
||||
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
|
||||
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
|
||||
|
||||
@@ -141,6 +157,54 @@ public class Contact extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmContent;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchUtmTerm;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchLandingPage;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstTouchReferrer;
|
||||
|
||||
@QField()
|
||||
private Instant firstTouchDate;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmSource;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmMedium;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmCampaign;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmContent;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchUtmTerm;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchLandingPage;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastTouchReferrer;
|
||||
|
||||
@QField()
|
||||
private Instant lastTouchDate;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -1248,4 +1312,453 @@ public class Contact extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmSource()
|
||||
{
|
||||
return (this.firstTouchUtmSource);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmSource(String firstTouchUtmSource)
|
||||
{
|
||||
this.firstTouchUtmSource = firstTouchUtmSource;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmSource(String firstTouchUtmSource)
|
||||
{
|
||||
this.firstTouchUtmSource = firstTouchUtmSource;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmMedium()
|
||||
{
|
||||
return (this.firstTouchUtmMedium);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmMedium(String firstTouchUtmMedium)
|
||||
{
|
||||
this.firstTouchUtmMedium = firstTouchUtmMedium;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmMedium(String firstTouchUtmMedium)
|
||||
{
|
||||
this.firstTouchUtmMedium = firstTouchUtmMedium;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmCampaign()
|
||||
{
|
||||
return (this.firstTouchUtmCampaign);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmCampaign(String firstTouchUtmCampaign)
|
||||
{
|
||||
this.firstTouchUtmCampaign = firstTouchUtmCampaign;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmCampaign(String firstTouchUtmCampaign)
|
||||
{
|
||||
this.firstTouchUtmCampaign = firstTouchUtmCampaign;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmContent()
|
||||
{
|
||||
return (this.firstTouchUtmContent);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmContent(String firstTouchUtmContent)
|
||||
{
|
||||
this.firstTouchUtmContent = firstTouchUtmContent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmContent(String firstTouchUtmContent)
|
||||
{
|
||||
this.firstTouchUtmContent = firstTouchUtmContent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchUtmTerm()
|
||||
{
|
||||
return (this.firstTouchUtmTerm);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchUtmTerm(String firstTouchUtmTerm)
|
||||
{
|
||||
this.firstTouchUtmTerm = firstTouchUtmTerm;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchUtmTerm(String firstTouchUtmTerm)
|
||||
{
|
||||
this.firstTouchUtmTerm = firstTouchUtmTerm;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchLandingPage()
|
||||
{
|
||||
return (this.firstTouchLandingPage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchLandingPage(String firstTouchLandingPage)
|
||||
{
|
||||
this.firstTouchLandingPage = firstTouchLandingPage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchLandingPage(String firstTouchLandingPage)
|
||||
{
|
||||
this.firstTouchLandingPage = firstTouchLandingPage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchReferrer
|
||||
*******************************************************************************/
|
||||
public String getFirstTouchReferrer()
|
||||
{
|
||||
return (this.firstTouchReferrer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchReferrer
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchReferrer(String firstTouchReferrer)
|
||||
{
|
||||
this.firstTouchReferrer = firstTouchReferrer;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchReferrer
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchReferrer(String firstTouchReferrer)
|
||||
{
|
||||
this.firstTouchReferrer = firstTouchReferrer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstTouchDate
|
||||
*******************************************************************************/
|
||||
public Instant getFirstTouchDate()
|
||||
{
|
||||
return (this.firstTouchDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for firstTouchDate
|
||||
*******************************************************************************/
|
||||
public void setFirstTouchDate(Instant firstTouchDate)
|
||||
{
|
||||
this.firstTouchDate = firstTouchDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstTouchDate
|
||||
*******************************************************************************/
|
||||
public Contact withFirstTouchDate(Instant firstTouchDate)
|
||||
{
|
||||
this.firstTouchDate = firstTouchDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmSource()
|
||||
{
|
||||
return (this.lastTouchUtmSource);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmSource(String lastTouchUtmSource)
|
||||
{
|
||||
this.lastTouchUtmSource = lastTouchUtmSource;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmSource
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmSource(String lastTouchUtmSource)
|
||||
{
|
||||
this.lastTouchUtmSource = lastTouchUtmSource;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmMedium()
|
||||
{
|
||||
return (this.lastTouchUtmMedium);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmMedium(String lastTouchUtmMedium)
|
||||
{
|
||||
this.lastTouchUtmMedium = lastTouchUtmMedium;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmMedium
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmMedium(String lastTouchUtmMedium)
|
||||
{
|
||||
this.lastTouchUtmMedium = lastTouchUtmMedium;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmCampaign()
|
||||
{
|
||||
return (this.lastTouchUtmCampaign);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmCampaign(String lastTouchUtmCampaign)
|
||||
{
|
||||
this.lastTouchUtmCampaign = lastTouchUtmCampaign;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmCampaign
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmCampaign(String lastTouchUtmCampaign)
|
||||
{
|
||||
this.lastTouchUtmCampaign = lastTouchUtmCampaign;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmContent()
|
||||
{
|
||||
return (this.lastTouchUtmContent);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmContent(String lastTouchUtmContent)
|
||||
{
|
||||
this.lastTouchUtmContent = lastTouchUtmContent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmContent
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmContent(String lastTouchUtmContent)
|
||||
{
|
||||
this.lastTouchUtmContent = lastTouchUtmContent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public String getLastTouchUtmTerm()
|
||||
{
|
||||
return (this.lastTouchUtmTerm);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public void setLastTouchUtmTerm(String lastTouchUtmTerm)
|
||||
{
|
||||
this.lastTouchUtmTerm = lastTouchUtmTerm;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchUtmTerm
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchUtmTerm(String lastTouchUtmTerm)
|
||||
{
|
||||
this.lastTouchUtmTerm = lastTouchUtmTerm;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public String getLastTouchLandingPage()
|
||||
{
|
||||
return (this.lastTouchLandingPage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public void setLastTouchLandingPage(String lastTouchLandingPage)
|
||||
{
|
||||
this.lastTouchLandingPage = lastTouchLandingPage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchLandingPage
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchLandingPage(String lastTouchLandingPage)
|
||||
{
|
||||
this.lastTouchLandingPage = lastTouchLandingPage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchReferrer
|
||||
*******************************************************************************/
|
||||
public String getLastTouchReferrer()
|
||||
{
|
||||
return (this.lastTouchReferrer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchReferrer
|
||||
*******************************************************************************/
|
||||
public void setLastTouchReferrer(String lastTouchReferrer)
|
||||
{
|
||||
this.lastTouchReferrer = lastTouchReferrer;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchReferrer
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchReferrer(String lastTouchReferrer)
|
||||
{
|
||||
this.lastTouchReferrer = lastTouchReferrer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTouchDate
|
||||
*******************************************************************************/
|
||||
public Instant getLastTouchDate()
|
||||
{
|
||||
return (this.lastTouchDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTouchDate
|
||||
*******************************************************************************/
|
||||
public void setLastTouchDate(Instant lastTouchDate)
|
||||
{
|
||||
this.lastTouchDate = lastTouchDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTouchDate
|
||||
*******************************************************************************/
|
||||
public Contact withLastTouchDate(Instant lastTouchDate)
|
||||
{
|
||||
this.lastTouchDate = lastTouchDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -35,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
@@ -48,7 +51,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
@QMetaDataProducingEntity(
|
||||
producePossibleValueSource = true,
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = EmailList.TableMetaDataCustomizer.class
|
||||
tableMetaDataCustomizer = EmailList.TableMetaDataCustomizer.class,
|
||||
childTables = {
|
||||
@ChildTable(childTableEntityClass = EmailListMember.class, joinFieldName = "emailListId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Members", enabled = true, maxRows = 25))
|
||||
}
|
||||
)
|
||||
public class EmailList extends QRecordEntity
|
||||
{
|
||||
@@ -74,8 +82,9 @@ public class EmailList extends QRecordEntity
|
||||
.withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withUniqueKey(new UniqueKey("slug"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "slug", "isActive", "isPublic", "sortOrder"))
|
||||
.withSection(SectionFactory.defaultT2("description", "memberCount"))
|
||||
.withUniqueKey(new UniqueKey("listKey"))
|
||||
.withSection(SectionFactory.defaultT1("id", "name", "listKey", "slug", "isActive", "isPublic", "sortOrder"))
|
||||
.withSection(SectionFactory.defaultT2("description", "memberCount", "requireDoubleOptIn", "consentText"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
return (table);
|
||||
@@ -84,6 +93,15 @@ public class EmailList extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String listKey;
|
||||
|
||||
@QField(isRequired = true, defaultValue = "true")
|
||||
private Boolean requireDoubleOptIn;
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -443,4 +461,89 @@ public class EmailList extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for listKey
|
||||
*******************************************************************************/
|
||||
public String getListKey()
|
||||
{
|
||||
return (this.listKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for listKey
|
||||
*******************************************************************************/
|
||||
public void setListKey(String listKey)
|
||||
{
|
||||
this.listKey = listKey;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for listKey
|
||||
*******************************************************************************/
|
||||
public EmailList withListKey(String listKey)
|
||||
{
|
||||
this.listKey = listKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requireDoubleOptIn
|
||||
*******************************************************************************/
|
||||
public Boolean getRequireDoubleOptIn()
|
||||
{
|
||||
return (this.requireDoubleOptIn);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for requireDoubleOptIn
|
||||
*******************************************************************************/
|
||||
public void setRequireDoubleOptIn(Boolean requireDoubleOptIn)
|
||||
{
|
||||
this.requireDoubleOptIn = requireDoubleOptIn;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requireDoubleOptIn
|
||||
*******************************************************************************/
|
||||
public EmailList withRequireDoubleOptIn(Boolean requireDoubleOptIn)
|
||||
{
|
||||
this.requireDoubleOptIn = requireDoubleOptIn;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public EmailList withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,6 +28,8 @@ 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.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
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;
|
||||
@@ -77,7 +79,7 @@ public class EmailListMember extends QRecordEntity
|
||||
.withRecordLabelFields("emailListId", "email")
|
||||
.withUniqueKey(new UniqueKey("emailListId", "email"))
|
||||
.withSection(SectionFactory.defaultT1("id", "emailListId", "contactId", "email", "firstName", "status"))
|
||||
.withSection(SectionFactory.defaultT2("subscribedDate", "unsubscribedDate", "subscriptionSource", "formSubmissionId"))
|
||||
.withSection(SectionFactory.defaultT2("subscribedDate", "unsubscribedDate", "subscriptionSource", "formSubmissionId", "source", "consentText", "confirmationTokenHash", "confirmationSentDate", "confirmedDate"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
@@ -86,12 +88,32 @@ public class EmailListMember extends QRecordEntity
|
||||
table.getField("formSubmissionId").withPossibleValueSourceFilter(new QQueryFilter(
|
||||
new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, "${input.contactId}")));
|
||||
|
||||
table.getField("email").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("firstName").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("consentText").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("confirmationTokenHash").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String confirmationTokenHash;
|
||||
|
||||
@QField()
|
||||
private Instant confirmationSentDate;
|
||||
|
||||
@QField()
|
||||
private Instant confirmedDate;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String source;
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -110,7 +132,7 @@ public class EmailListMember extends QRecordEntity
|
||||
@QField(isRequired = true, possibleValueSourceName = CrmListMemberStatus.NAME, defaultValue = "1")
|
||||
private Integer status;
|
||||
|
||||
@QField(isRequired = true)
|
||||
@QField()
|
||||
private Instant subscribedDate;
|
||||
|
||||
@QField()
|
||||
@@ -519,4 +541,145 @@ public class EmailListMember extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmationTokenHash
|
||||
*******************************************************************************/
|
||||
public String getConfirmationTokenHash()
|
||||
{
|
||||
return (this.confirmationTokenHash);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for confirmationTokenHash
|
||||
*******************************************************************************/
|
||||
public void setConfirmationTokenHash(String confirmationTokenHash)
|
||||
{
|
||||
this.confirmationTokenHash = confirmationTokenHash;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmationTokenHash
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConfirmationTokenHash(String confirmationTokenHash)
|
||||
{
|
||||
this.confirmationTokenHash = confirmationTokenHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmationSentDate
|
||||
*******************************************************************************/
|
||||
public Instant getConfirmationSentDate()
|
||||
{
|
||||
return (this.confirmationSentDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for confirmationSentDate
|
||||
*******************************************************************************/
|
||||
public void setConfirmationSentDate(Instant confirmationSentDate)
|
||||
{
|
||||
this.confirmationSentDate = confirmationSentDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmationSentDate
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConfirmationSentDate(Instant confirmationSentDate)
|
||||
{
|
||||
this.confirmationSentDate = confirmationSentDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmedDate
|
||||
*******************************************************************************/
|
||||
public Instant getConfirmedDate()
|
||||
{
|
||||
return (this.confirmedDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for confirmedDate
|
||||
*******************************************************************************/
|
||||
public void setConfirmedDate(Instant confirmedDate)
|
||||
{
|
||||
this.confirmedDate = confirmedDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmedDate
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConfirmedDate(Instant confirmedDate)
|
||||
{
|
||||
this.confirmedDate = confirmedDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for source
|
||||
*******************************************************************************/
|
||||
public String getSource()
|
||||
{
|
||||
return (this.source);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for source
|
||||
*******************************************************************************/
|
||||
public void setSource(String source)
|
||||
{
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for source
|
||||
*******************************************************************************/
|
||||
public EmailListMember withSource(String source)
|
||||
{
|
||||
this.source = source;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public EmailListMember withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -47,7 +49,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for FormSubmission table -- raw website form submissions.
|
||||
** Captures web-specific metadata (UTM, referrer, IP) that does not belong on
|
||||
** Captures web-specific metadata (UTM, referrer, hashed client identifiers) that does not belong on
|
||||
** crm_contact. Submissions are converted to contacts via a process.
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
@@ -77,22 +79,50 @@ public class FormSubmission extends QRecordEntity
|
||||
.withIcon(new QIcon().withName("dynamic_form"))
|
||||
.withRecordLabelFormat("%s - %s %s")
|
||||
.withRecordLabelFields("formType", "firstName", "email")
|
||||
.withSection(SectionFactory.defaultT1("id", "formType", "email", "firstName", "lastName"))
|
||||
.withSection(SectionFactory.defaultT2("company", "phone", "message"))
|
||||
.withSection(SectionFactory.defaultT1("id", "formKey", "formType", "email", "firstName", "lastName"))
|
||||
.withSection(SectionFactory.defaultT2("company", "phone", "message", "marketingConsent", "consentText"))
|
||||
.withSection(new QFieldSection("utm", new QIcon().withName("campaign"), Tier.T2,
|
||||
List.of("sourceUrl", "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", "referrer")))
|
||||
List.of("sourceUrl", "landingPage", "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", "referrer")))
|
||||
.withSection(new QFieldSection("conversion", new QIcon().withName("swap_horiz"), Tier.T2,
|
||||
List.of("contactId", "conversionStatus", "convertedDate", "convertedByUserId")))
|
||||
.withSection(new QFieldSection("technical", new QIcon().withName("code"), Tier.T2,
|
||||
List.of("ipAddress", "userAgent", "metadata")))
|
||||
List.of("visitorKey", "clientHash", "metadata")))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
table.getField("firstName").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("lastName").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("email").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("phone").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("company").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("message").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("consentText").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("visitorKey").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("clientHash").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String formKey;
|
||||
|
||||
@QField(defaultValue = "false")
|
||||
private Boolean marketingConsent;
|
||||
|
||||
@QField()
|
||||
private String consentText;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String landingPage;
|
||||
|
||||
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String visitorKey;
|
||||
|
||||
@QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String clientHash;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -150,12 +180,6 @@ public class FormSubmission extends QRecordEntity
|
||||
@QField()
|
||||
private String convertedByUserId;
|
||||
|
||||
@QField(maxLength = 45, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String ipAddress;
|
||||
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String userAgent;
|
||||
|
||||
@QField()
|
||||
private String metadata;
|
||||
|
||||
@@ -775,68 +799,6 @@ public class FormSubmission extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ipAddress
|
||||
*******************************************************************************/
|
||||
public String getIpAddress()
|
||||
{
|
||||
return (this.ipAddress);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public void setIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ipAddress
|
||||
*******************************************************************************/
|
||||
public FormSubmission withIpAddress(String ipAddress)
|
||||
{
|
||||
this.ipAddress = ipAddress;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userAgent
|
||||
*******************************************************************************/
|
||||
public String getUserAgent()
|
||||
{
|
||||
return (this.userAgent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userAgent
|
||||
*******************************************************************************/
|
||||
public void setUserAgent(String userAgent)
|
||||
{
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userAgent
|
||||
*******************************************************************************/
|
||||
public FormSubmission withUserAgent(String userAgent)
|
||||
{
|
||||
this.userAgent = userAgent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metadata
|
||||
*******************************************************************************/
|
||||
@@ -928,4 +890,173 @@ public class FormSubmission extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for formKey
|
||||
*******************************************************************************/
|
||||
public String getFormKey()
|
||||
{
|
||||
return (this.formKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for formKey
|
||||
*******************************************************************************/
|
||||
public void setFormKey(String formKey)
|
||||
{
|
||||
this.formKey = formKey;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for formKey
|
||||
*******************************************************************************/
|
||||
public FormSubmission withFormKey(String formKey)
|
||||
{
|
||||
this.formKey = formKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for marketingConsent
|
||||
*******************************************************************************/
|
||||
public Boolean getMarketingConsent()
|
||||
{
|
||||
return (this.marketingConsent);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for marketingConsent
|
||||
*******************************************************************************/
|
||||
public void setMarketingConsent(Boolean marketingConsent)
|
||||
{
|
||||
this.marketingConsent = marketingConsent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for marketingConsent
|
||||
*******************************************************************************/
|
||||
public FormSubmission withMarketingConsent(Boolean marketingConsent)
|
||||
{
|
||||
this.marketingConsent = marketingConsent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consentText
|
||||
*******************************************************************************/
|
||||
public String getConsentText()
|
||||
{
|
||||
return (this.consentText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consentText
|
||||
*******************************************************************************/
|
||||
public void setConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consentText
|
||||
*******************************************************************************/
|
||||
public FormSubmission withConsentText(String consentText)
|
||||
{
|
||||
this.consentText = consentText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for landingPage
|
||||
*******************************************************************************/
|
||||
public String getLandingPage()
|
||||
{
|
||||
return (this.landingPage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for landingPage
|
||||
*******************************************************************************/
|
||||
public void setLandingPage(String landingPage)
|
||||
{
|
||||
this.landingPage = landingPage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for landingPage
|
||||
*******************************************************************************/
|
||||
public FormSubmission withLandingPage(String landingPage)
|
||||
{
|
||||
this.landingPage = landingPage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for visitorKey
|
||||
*******************************************************************************/
|
||||
public String getVisitorKey()
|
||||
{
|
||||
return (this.visitorKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for visitorKey
|
||||
*******************************************************************************/
|
||||
public void setVisitorKey(String visitorKey)
|
||||
{
|
||||
this.visitorKey = visitorKey;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for visitorKey
|
||||
*******************************************************************************/
|
||||
public FormSubmission withVisitorKey(String visitorKey)
|
||||
{
|
||||
this.visitorKey = visitorKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientHash
|
||||
*******************************************************************************/
|
||||
public String getClientHash()
|
||||
{
|
||||
return (this.clientHash);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientHash
|
||||
*******************************************************************************/
|
||||
public void setClientHash(String clientHash)
|
||||
{
|
||||
this.clientHash = clientHash;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientHash
|
||||
*******************************************************************************/
|
||||
public FormSubmission withClientHash(String clientHash)
|
||||
{
|
||||
this.clientHash = clientHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -40,7 +40,8 @@ public enum CrmConsentType implements PossibleValueEnum<Integer>
|
||||
EMAIL(1, "Email"),
|
||||
CALL(2, "Call"),
|
||||
SMS(3, "SMS"),
|
||||
DATA_PROCESSING(4, "Data Processing");
|
||||
DATA_PROCESSING(4, "Data Processing"),
|
||||
REPLY_TO_INQUIRY(5, "Reply to inquiry");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
@@ -40,7 +40,8 @@ public enum CrmListMemberStatus implements PossibleValueEnum<Integer>
|
||||
SUBSCRIBED(1, "Subscribed"),
|
||||
UNSUBSCRIBED(2, "Unsubscribed"),
|
||||
BOUNCED(3, "Bounced"),
|
||||
CLEANED(4, "Cleaned");
|
||||
CLEANED(4, "Cleaned"),
|
||||
PENDING_CONFIRMATION(5, "Pending confirmation");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bulk action process to assign a new owner to selected records. Works on
|
||||
** any table that has an ownerUserId field.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer for the BulkAssignOwner table-level process. Uses the
|
||||
** StreamedETL pattern with a transform step that sets ownerUserId on each
|
||||
** record to the newOwnerUserId input value.
|
||||
*******************************************************************************/
|
||||
public class BulkAssignOwnerProcess implements MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_bulk_assign_owner";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
|
||||
.withName(NAME)
|
||||
.withLabel("Bulk Assign Owner")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("person_pin"))
|
||||
.withTransformStepClass(BulkAssignOwnerTransformStep.class)
|
||||
.withPreviewMessage("Records to be assigned a new owner")
|
||||
.withReviewStepRecordFields(List.of(
|
||||
new QFieldMetaData("id", QFieldType.INTEGER),
|
||||
new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Current Owner")
|
||||
))
|
||||
.getProcessMetaData();
|
||||
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform step that sets ownerUserId on each record.
|
||||
*******************************************************************************/
|
||||
public static class BulkAssignOwnerTransformStep extends AbstractTransformStep
|
||||
{
|
||||
private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
|
||||
.withSingularFutureMessage("will be assigned a new owner")
|
||||
.withPluralFutureMessage("will be assigned a new owner")
|
||||
.withSingularPastMessage("was assigned a new owner")
|
||||
.withPluralPastMessage("were assigned a new owner");
|
||||
|
||||
/*******************************************************************************
|
||||
** Set ownerUserId on each record from the newOwnerUserId input.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String newOwnerUserId = input.getValueString("newOwnerUserId");
|
||||
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
record.setValue("ownerUserId", newOwnerUserId);
|
||||
output.addRecord(record);
|
||||
okLine.incrementCountAndAddPrimaryKey(record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provide process summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
okLine.addSelfToListIfAnyCount(rs);
|
||||
return (rs);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+2
-35
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
@@ -54,52 +53,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for converting a form submission into a
|
||||
** Backend operation used by the streamed CRM actions for converting a form submission into a
|
||||
** contact record. Deduplicates on email, updates the form with conversion
|
||||
** data, and creates a Note activity on the contact.
|
||||
*******************************************************************************/
|
||||
public class ConvertFormToContactProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ConvertFormToContactProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_convert_form_to_contact";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Convert Form to Contact")
|
||||
.withTableName(FormSubmission.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("swap_horiz"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ConvertFormToContactProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("formSubmissionId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the conversion process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -41,49 +41,18 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for detecting duplicate contacts or companies.
|
||||
** Backend operation used by the streamed CRM actions for detecting duplicate contacts or companies.
|
||||
*******************************************************************************/
|
||||
public class DetectDuplicatesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class DetectDuplicatesProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_detect_duplicates";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Detect Duplicates")
|
||||
.withIcon(new QIcon().withName("find_replace"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(DetectDuplicatesProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the detect duplicates process.
|
||||
*******************************************************************************/
|
||||
|
||||
+2
-35
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.ConsentRecord;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
@@ -52,21 +51,12 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for GDPR anonymization of a contact.
|
||||
** Backend operation used by the streamed CRM actions for GDPR anonymization of a contact.
|
||||
*******************************************************************************/
|
||||
public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class GdprAnonymizeContactProcess implements BackendStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class);
|
||||
|
||||
@@ -76,29 +66,6 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("GDPR Anonymize Contact")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("privacy_tip"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(GdprAnonymizeContactProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the GDPR anonymization process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Simplified import process for contacts. Takes a list of QRecords,
|
||||
** validates required fields, checks for duplicates by email, inserts new
|
||||
** contacts, with core table auditing.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for importing contacts from QRecords.
|
||||
*******************************************************************************/
|
||||
public class ImportContactsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_import_contacts";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Import Contacts")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("upload"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ImportContactsProcess.class))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the import contacts process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> inputRecords = input.getRecords();
|
||||
String sessionUserId = QContext.getQSession().getIdReference();
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// build set of existing emails for duplicate detection //
|
||||
////////////////////////////////////////////////////////////
|
||||
Set<String> existingEmails = new HashSet<>();
|
||||
QueryOutput existingContacts = new QueryAction().execute(
|
||||
new QueryInput(Contact.TABLE_NAME));
|
||||
for(QRecord record : existingContacts.getRecords())
|
||||
{
|
||||
String email = record.getValueString("email");
|
||||
if(StringUtils.hasContent(email))
|
||||
{
|
||||
existingEmails.add(email.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
int importedCount = 0;
|
||||
int skippedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for(QRecord inputRecord : inputRecords)
|
||||
{
|
||||
String firstName = inputRecord.getValueString("firstName");
|
||||
String lastName = inputRecord.getValueString("lastName");
|
||||
String email = inputRecord.getValueString("email");
|
||||
|
||||
///////////////////////////////////////////
|
||||
// validate required fields //
|
||||
///////////////////////////////////////////
|
||||
if(!StringUtils.hasContent(firstName) || !StringUtils.hasContent(lastName) || !StringUtils.hasContent(email))
|
||||
{
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// check for duplicate by email //
|
||||
///////////////////////////////////////////
|
||||
if(existingEmails.contains(email.toLowerCase()))
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// insert the new contact //
|
||||
///////////////////////////////////////////
|
||||
QRecord newContact = new QRecord()
|
||||
.withValue("firstName", firstName)
|
||||
.withValue("lastName", lastName)
|
||||
.withValue("email", email)
|
||||
.withValue("phone", inputRecord.getValueString("phone"))
|
||||
.withValue("ownerUserId", sessionUserId);
|
||||
|
||||
new InsertAction().execute(
|
||||
new InsertInput(Contact.TABLE_NAME).withRecord(newContact));
|
||||
|
||||
existingEmails.add(email.toLowerCase());
|
||||
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
output.addValue("importedCount", importedCount);
|
||||
output.addValue("skippedCount", skippedCount);
|
||||
output.addValue("errorCount", errorCount);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -50,50 +50,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for merging two companies into one.
|
||||
** Backend operation used by the streamed CRM actions for merging two companies into one.
|
||||
*******************************************************************************/
|
||||
public class MergeCompaniesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class MergeCompaniesProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_merge_companies";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Merge Companies")
|
||||
.withTableName(Company.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("merge"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(MergeCompaniesProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("primaryCompanyId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("secondaryCompanyId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the merge companies process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -52,50 +52,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for merging two contacts into one.
|
||||
** Backend operation used by the streamed CRM actions for merging two contacts into one.
|
||||
*******************************************************************************/
|
||||
public class MergeContactsProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class MergeContactsProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_merge_contacts";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Merge Contacts")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("merge"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(MergeContactsProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("primaryContactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("secondaryContactId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the merge contacts process.
|
||||
*******************************************************************************/
|
||||
@@ -125,6 +92,8 @@ public class MergeContactsProcess implements BackendStep, MetaDataProducerInterf
|
||||
// Re-link activities from secondary //
|
||||
///////////////////////////////////////////////
|
||||
relinkRecords(Activity.TABLE_NAME, "contactId", secondaryContactId, primaryContactId);
|
||||
relinkRecords("crm_email_message", "contactId", secondaryContactId, primaryContactId);
|
||||
relinkRecords("crm_deal", "primaryContactId", secondaryContactId, primaryContactId);
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// Re-link DealContact rows //
|
||||
|
||||
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.EmailListMember;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
@@ -47,54 +46,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to subscribe an email address to a list.
|
||||
** Backend operation used by the streamed CRM actions to subscribe an email address to a list.
|
||||
** Handles: new subscription, resubscription after unsub, and no-op for
|
||||
** existing active subscribers.
|
||||
*******************************************************************************/
|
||||
public class SubscribeToListProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class SubscribeToListProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_subscribe_to_list";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Subscribe to List")
|
||||
.withIcon(new QIcon().withName("person_add"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(SubscribeToListProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("firstName", QFieldType.STRING),
|
||||
new QFieldMetaData("subscriptionSource", QFieldType.STRING)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the subscription logic.
|
||||
*******************************************************************************/
|
||||
|
||||
+2
-35
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.core.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.EmailListMember;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmListMemberStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
@@ -44,51 +43,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to unsubscribe an email from a list.
|
||||
** Backend operation used by the streamed CRM actions to unsubscribe an email from a list.
|
||||
** Queries by emailListId + email, sets status to UNSUBSCRIBED and
|
||||
** unsubscribedDate to now.
|
||||
*******************************************************************************/
|
||||
public class UnsubscribeFromListProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class UnsubscribeFromListProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_unsubscribe_from_list";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Unsubscribe from List")
|
||||
.withIcon(new QIcon().withName("unsubscribe"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(UnsubscribeFromListProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("emailListId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the unsubscription.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -33,6 +33,7 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
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;
|
||||
@@ -118,7 +119,7 @@ public class Deal extends QRecordEntity
|
||||
.withRecordLabelFields("name")
|
||||
.withSection(t1Section)
|
||||
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
|
||||
List.of("companyId", "ownerUserId", "leadSourceId", "priority",
|
||||
List.of("primaryContactId", "companyId", "ownerUserId", "leadSourceId", "priority",
|
||||
"probabilityOverridePct", "expectedCloseDate", "actualCloseDate",
|
||||
"winLossReasonId", "weightedAmount", "amountInBaseCurrency")))
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
@@ -191,6 +192,9 @@ public class Deal extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Company.TABLE_NAME)
|
||||
private Integer companyId;
|
||||
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer primaryContactId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private String ownerUserId;
|
||||
|
||||
@@ -894,4 +898,32 @@ public class Deal extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The contact primarily responsible for this opportunity.
|
||||
*******************************************************************************/
|
||||
public Integer getPrimaryContactId()
|
||||
{
|
||||
return (primaryContactId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the primary contact.
|
||||
*******************************************************************************/
|
||||
public void setPrimaryContactId(Integer primaryContactId)
|
||||
{
|
||||
this.primaryContactId = primaryContactId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the primary contact fluently.
|
||||
*******************************************************************************/
|
||||
public Deal withPrimaryContactId(Integer primaryContactId)
|
||||
{
|
||||
this.primaryContactId = primaryContactId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,6 @@ package com.kingsrook.qbits.crm.deals.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealContact;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealProduct;
|
||||
@@ -50,49 +49,17 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for cloning a deal and its child records.
|
||||
** Backend operation used by the streamed CRM actions for cloning a deal and its child records.
|
||||
*******************************************************************************/
|
||||
public class CloneDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class CloneDealProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_clone_deal";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Clone Deal")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("content_copy"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(CloneDealProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the clone deal process.
|
||||
*******************************************************************************/
|
||||
@@ -127,6 +94,7 @@ public class CloneDealProcess implements BackendStep, MetaDataProducerInterface<
|
||||
.withValue("amount", sourceDeal.getAmount())
|
||||
.withValue("currencyCode", sourceDeal.getCurrencyCode())
|
||||
.withValue("companyId", sourceDeal.getCompanyId())
|
||||
.withValue("primaryContactId", sourceDeal.getPrimaryContactId())
|
||||
.withValue("ownerUserId", sourceDeal.getOwnerUserId())
|
||||
.withValue("leadSourceId", sourceDeal.getLeadSourceId())
|
||||
.withValue("expectedCloseDate", sourceDeal.getExpectedCloseDate())
|
||||
|
||||
@@ -30,7 +30,6 @@ package com.kingsrook.qbits.crm.deals.processes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.LifecycleStage;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
@@ -57,55 +56,20 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for converting a lead (contact) into a
|
||||
** Backend operation used by the streamed CRM actions for converting a lead (contact) into a
|
||||
** deal opportunity. Transitions the contact's lifecycle stage, creates
|
||||
** a deal with the first pipeline stage, and links them via DealContact.
|
||||
*******************************************************************************/
|
||||
public class ConvertLeadProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ConvertLeadProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_convert_lead";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Convert Lead")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("trending_up"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ConvertLeadProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("pipelineId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("dealName", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("dealAmount", QFieldType.DECIMAL)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the lead conversion process.
|
||||
*******************************************************************************/
|
||||
|
||||
+14
-38
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealContact;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
@@ -49,55 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for adding or removing a contact on a deal.
|
||||
** Backend operation used by the streamed CRM actions for adding or removing a contact on a deal.
|
||||
** ADD: inserts a DealContact row, with isPrimary enforcement.
|
||||
** REMOVE: deletes the DealContact row matching dealId + contactId.
|
||||
*******************************************************************************/
|
||||
public class ManageContactOnDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ManageContactOnDealProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_manage_contact_on_deal";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Manage Contact on Deal")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("person_add"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ManageContactOnDealProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("action", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("contactRoleId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("isPrimary", QFieldType.BOOLEAN)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the manage contact process.
|
||||
*******************************************************************************/
|
||||
@@ -131,6 +96,11 @@ public class ManageContactOnDealProcess implements BackendStep, MetaDataProducer
|
||||
|
||||
new InsertAction().execute(
|
||||
new InsertInput(DealContact.TABLE_NAME).withRecordEntity(dealContact));
|
||||
if(Boolean.TRUE.equals(isPrimary))
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("id", dealId).withValue("primaryContactId", contactId)));
|
||||
}
|
||||
}
|
||||
else if("REMOVE".equalsIgnoreCase(action))
|
||||
{
|
||||
@@ -156,6 +126,12 @@ public class ManageContactOnDealProcess implements BackendStep, MetaDataProducer
|
||||
deleteInput.setTableName(DealContact.TABLE_NAME);
|
||||
deleteInput.setPrimaryKeys(idsToDelete.stream().map(id -> (java.io.Serializable) id).toList());
|
||||
new DeleteAction().execute(deleteInput);
|
||||
QRecord deal = GetAction.execute(Deal.TABLE_NAME, dealId);
|
||||
if(deal != null && contactId.equals(deal.getValueInteger("primaryContactId")))
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("id", dealId).withValue("primaryContactId", null)));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -50,55 +50,22 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for moving a deal to a new pipeline stage.
|
||||
** Backend operation used by the streamed CRM actions for moving a deal to a new pipeline stage.
|
||||
** Enforces stage-belongs-to-pipeline validation, required field gating,
|
||||
** weighted amount recalculation, close-date stamping, and stage history
|
||||
** tracking.
|
||||
*******************************************************************************/
|
||||
public class MoveDealStageProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class MoveDealStageProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_move_deal_stage";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Move Deal Stage")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("swap_horiz"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(MoveDealStageProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the stage move process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -31,7 +31,6 @@ package com.kingsrook.qbits.crm.deals.processes;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
@@ -47,52 +46,19 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for reopening a closed deal by moving it
|
||||
** Backend operation used by the streamed CRM actions for reopening a closed deal by moving it
|
||||
** to an active pipeline stage. Validates the deal is currently closed, the
|
||||
** target stage is not closed, clears close data, and logs the transition.
|
||||
*******************************************************************************/
|
||||
public class ReopenDealProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class ReopenDealProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_reopen_deal";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Reopen Deal")
|
||||
.withTableName(Deal.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("replay"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(ReopenDealProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("targetStageId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the reopen process.
|
||||
*******************************************************************************/
|
||||
|
||||
+2
-35
@@ -27,7 +27,6 @@
|
||||
package com.kingsrook.qbits.crm.deals.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Company;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType;
|
||||
@@ -46,50 +45,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for transferring ownership of contacts,
|
||||
** Backend operation used by the streamed CRM actions for transferring ownership of contacts,
|
||||
** companies, and deals from one user to another.
|
||||
*******************************************************************************/
|
||||
public class TransferOwnershipProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class TransferOwnershipProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_transfer_ownership";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Transfer Ownership")
|
||||
.withIcon(new QIcon().withName("swap_horiz"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(TransferOwnershipProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("fromUserId", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("toUserId", QFieldType.STRING).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the transfer ownership process.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.email;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.api.CrmReadiness;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiEffectOutcome;
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only CRM adapter that imports the optional qbit-email public API; otherwise delegates to core messaging.
|
||||
*******************************************************************************/
|
||||
public class CrmEmailTransport
|
||||
{
|
||||
private final CrmQBitConfig config;
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the transport for this instance. Subclass send for a host/test transport.
|
||||
*******************************************************************************/
|
||||
public CrmEmailTransport(CrmQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check class presence before linking the optional public API.
|
||||
*******************************************************************************/
|
||||
private boolean emailInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
|
||||
return (EmailApi.isInstalled(QContext.getQInstance()));
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report a usable configured transport. An installed but unready email qbit is not bypassed.
|
||||
*******************************************************************************/
|
||||
public CrmReadiness readiness() throws QException
|
||||
{
|
||||
if(emailInstalled())
|
||||
{
|
||||
return (EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING).ready()
|
||||
? CrmReadiness.QBIT_EMAIL : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
return (config.getMessagingProviderName() != null && QContext.getQInstance().getMessagingProvider(config.getMessagingProviderName()) != null
|
||||
? CrmReadiness.CORE_MESSAGING : CrmReadiness.NOT_CONFIGURED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliver one message, using a stable CRM purpose/id key for the durable email outbox.
|
||||
*******************************************************************************/
|
||||
public void send(String purpose, String id, Integer contactId, String address, String subject, String body, boolean marketing) throws QException
|
||||
{
|
||||
if(emailInstalled())
|
||||
{
|
||||
String key = "crm:" + purpose + ":" + id;
|
||||
var result = EmailApi.sendOnce(new EmailApiRequest().withTo(address).withSubject(subject).withMarkdownBody(body)
|
||||
.withCategory(marketing ? EmailApiCategory.MARKETING : EmailApiCategory.TRANSACTIONAL)
|
||||
.withLinkedRecord("crm_contact", contactId).withSender("crm", purpose).withIdempotencyKey(key), key);
|
||||
if(result.outcome() == EmailApiEffectOutcome.REFUSED)
|
||||
{
|
||||
throw (new QUserFacingException("The email service could not accept this message."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(readiness() != CrmReadiness.CORE_MESSAGING)
|
||||
{
|
||||
throw (new QUserFacingException("CRM email is not configured. Install qbit-email or configure a messaging provider."));
|
||||
}
|
||||
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName(config.getMessagingProviderName())
|
||||
.withTo(new Party().withAddress(address).withRole(EmailPartyRole.TO)).withSubject(subject)
|
||||
.withContent(new Content().withBody(body).withContentRole(EmailContentRole.TEXT)));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ package com.kingsrook.qbits.crm.email.model;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
@@ -86,7 +87,7 @@ public class EmailMessage extends QRecordEntity
|
||||
List.of("isTracked", "openCount", "clickCount", "firstOpenedDate", "firstClickedDate",
|
||||
"bounceType", "isSpamComplaint")))
|
||||
.withSection(new QFieldSection("references", new QIcon("link"), Tier.T2,
|
||||
List.of("activityId", "emailTemplateId", "sequenceEnrollmentId",
|
||||
List.of("contactId", "activityId", "emailTemplateId", "sequenceEnrollmentId",
|
||||
"threadId", "replyToEmailId")))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
|
||||
@@ -102,6 +103,9 @@ public class EmailMessage extends QRecordEntity
|
||||
@QField(possibleValueSourceName = Activity.TABLE_NAME)
|
||||
private Integer activityId;
|
||||
|
||||
@QField(possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer contactId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String fromAddress;
|
||||
|
||||
@@ -999,4 +1003,32 @@ public class EmailMessage extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Contact whose record includes this message.
|
||||
*******************************************************************************/
|
||||
public Integer getContactId()
|
||||
{
|
||||
return (contactId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the message's contact.
|
||||
*******************************************************************************/
|
||||
public void setContactId(Integer contactId)
|
||||
{
|
||||
this.contactId = contactId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the message's contact fluently.
|
||||
*******************************************************************************/
|
||||
public EmailMessage withContactId(Integer contactId)
|
||||
{
|
||||
this.contactId = contactId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,6 +28,7 @@ package com.kingsrook.qbits.crm.email.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
@@ -97,7 +98,7 @@ public class SequenceEnrollment extends QRecordEntity
|
||||
@QField(isRequired = true, possibleValueSourceName = EmailSequence.TABLE_NAME)
|
||||
private Integer sequenceId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
@QField(isRequired = true, possibleValueSourceName = Contact.TABLE_NAME)
|
||||
private Integer contactId;
|
||||
|
||||
@QField()
|
||||
|
||||
@@ -28,7 +28,6 @@ package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailSequence;
|
||||
@@ -51,52 +50,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to enroll a contact in an email sequence.
|
||||
** Backend operation used by the streamed CRM actions to enroll a contact in an email sequence.
|
||||
** Validates that the contact is not already enrolled, the sequence is active,
|
||||
** and the contact has not opted out of email.
|
||||
*******************************************************************************/
|
||||
public class EnrollInSequenceProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class EnrollInSequenceProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_enroll_in_sequence";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Enroll in Sequence")
|
||||
.withIcon(new QIcon().withName("playlist_add"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(EnrollInSequenceProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("sequenceId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the enrollment logic.
|
||||
*******************************************************************************/
|
||||
|
||||
+28
-18
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
@@ -88,6 +89,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withIsHidden(true)
|
||||
.withLabel("Process Sequence Steps")
|
||||
.withIcon(new QIcon().withName("play_circle"))
|
||||
// Scheduling is configured by the host application via ScheduledJob records
|
||||
@@ -109,29 +111,37 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce
|
||||
///////////////////////////////////////////////////////////
|
||||
// query enrollments where status=ACTIVE and due now //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput enrollmentQuery = new QueryAction().execute(
|
||||
new QueryInput(SequenceEnrollment.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))
|
||||
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))));
|
||||
|
||||
int processedCount = 0;
|
||||
|
||||
for(QRecord enrollmentRecord : enrollmentQuery.getRecords())
|
||||
Integer lastId = null;
|
||||
Instant dueThrough = Instant.now();
|
||||
while(true)
|
||||
{
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
|
||||
|
||||
try
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId()))
|
||||
.withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, dueThrough))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(200);
|
||||
if(lastId != null)
|
||||
{
|
||||
processEnrollment(enrollment);
|
||||
processedCount++;
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
}
|
||||
catch(Exception e)
|
||||
List<QRecord> page = new QueryAction().execute(new QueryInput(SequenceEnrollment.TABLE_NAME).withFilter(filter)).getRecords();
|
||||
for(QRecord enrollmentRecord : page)
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// handle step failure //
|
||||
/////////////////////////////////////////////
|
||||
handleFailure(enrollment, e.getMessage());
|
||||
SequenceEnrollment enrollment = new SequenceEnrollment(enrollmentRecord);
|
||||
try
|
||||
{
|
||||
processEnrollment(enrollment);
|
||||
processedCount++;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
handleFailure(enrollment, e.getMessage());
|
||||
}
|
||||
lastId = enrollment.getId();
|
||||
}
|
||||
if(page.size() < 200)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process to send an email to a contact. Creates an Activity and an
|
||||
** EmailMessage record, respecting doNotEmail flags and optional template
|
||||
** lookups.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailMessage;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailTemplate;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for sending an email to a CRM contact.
|
||||
** Validates doNotEmail, optionally loads a template, creates an Activity
|
||||
** record, and creates a tracked EmailMessage record.
|
||||
*******************************************************************************/
|
||||
public class SendEmailProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_send_email";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Send Email")
|
||||
.withTableName(Contact.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("email"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(SendEmailProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("contactId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("subject", QFieldType.STRING),
|
||||
new QFieldMetaData("bodyHtml", QFieldType.STRING),
|
||||
new QFieldMetaData("bodyText", QFieldType.STRING),
|
||||
new QFieldMetaData("emailTemplateId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("dealId", QFieldType.INTEGER),
|
||||
new QFieldMetaData("ccAddresses", QFieldType.STRING),
|
||||
new QFieldMetaData("bccAddresses", QFieldType.STRING)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the send-email process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer contactId = input.getValueInteger("contactId");
|
||||
String subject = input.getValueString("subject");
|
||||
String bodyHtml = input.getValueString("bodyHtml");
|
||||
String bodyText = input.getValueString("bodyText");
|
||||
Integer emailTemplateId = input.getValueInteger("emailTemplateId");
|
||||
Integer dealId = input.getValueInteger("dealId");
|
||||
String ccAddresses = input.getValueString("ccAddresses");
|
||||
String bccAddresses = input.getValueString("bccAddresses");
|
||||
String sessionUserId = CrmSessionUtils.getCurrentUserId();
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Step 1: load and validate the contact //
|
||||
///////////////////////////////////////////
|
||||
QRecord contactRecord = GetAction.execute(Contact.TABLE_NAME, contactId);
|
||||
if(contactRecord == null)
|
||||
{
|
||||
throw new QException("Contact not found: " + contactId);
|
||||
}
|
||||
|
||||
Contact contact = new Contact(contactRecord);
|
||||
if(Boolean.TRUE.equals(contact.getDoNotEmail()))
|
||||
{
|
||||
throw new QUserFacingException("Cannot send email: contact has doNotEmail set to true.");
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// Step 2: if template provided, load and apply it //
|
||||
//////////////////////////////////////////////////////
|
||||
if(emailTemplateId != null)
|
||||
{
|
||||
QRecord templateRecord = GetAction.execute(EmailTemplate.TABLE_NAME, emailTemplateId);
|
||||
if(templateRecord != null)
|
||||
{
|
||||
EmailTemplate template = new EmailTemplate(templateRecord);
|
||||
if(subject == null)
|
||||
{
|
||||
subject = template.getSubject();
|
||||
}
|
||||
if(bodyHtml == null)
|
||||
{
|
||||
bodyHtml = template.getBodyHtml();
|
||||
}
|
||||
if(bodyText == null)
|
||||
{
|
||||
bodyText = template.getBodyText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
// Step 3: look up Email activity type //
|
||||
/////////////////////////////////////////
|
||||
Integer emailTypeId = lookupEmailActivityTypeId();
|
||||
|
||||
//////////////////////////////////////////
|
||||
// Step 4: create the Activity record //
|
||||
//////////////////////////////////////////
|
||||
Activity activity = new Activity()
|
||||
.withActivityTypeId(emailTypeId)
|
||||
.withSubject(subject)
|
||||
.withContactId(contactId)
|
||||
.withDealId(dealId)
|
||||
.withDirection(CrmDirection.OUTBOUND.getPossibleValueId())
|
||||
.withOwnerUserId(sessionUserId)
|
||||
.withIsCompleted(true);
|
||||
|
||||
InsertOutput activityInsert = new InsertAction().execute(
|
||||
new InsertInput(Activity.TABLE_NAME).withRecordEntity(activity));
|
||||
Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id");
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// Step 5: create the EmailMessage record //
|
||||
//////////////////////////////////////////////
|
||||
String toAddress = contact.getEmail() != null ? contact.getEmail() : "";
|
||||
|
||||
EmailMessage emailMessage = new EmailMessage()
|
||||
.withActivityId(activityId)
|
||||
.withFromAddress(sessionUserId)
|
||||
.withFromName(sessionUserId)
|
||||
.withToAddresses(toAddress)
|
||||
.withCcAddresses(ccAddresses)
|
||||
.withBccAddresses(bccAddresses)
|
||||
.withSubject(subject != null ? subject : "")
|
||||
.withBodyHtml(bodyHtml)
|
||||
.withBodyText(bodyText)
|
||||
.withDirection(CrmDirection.OUTBOUND.getPossibleValueId())
|
||||
.withStatus(CrmEmailStatus.SENT.getPossibleValueId())
|
||||
.withSentDate(Instant.now())
|
||||
.withIsTracked(true)
|
||||
.withOpenCount(0)
|
||||
.withClickCount(0)
|
||||
.withEmailTemplateId(emailTemplateId);
|
||||
|
||||
InsertOutput emailInsert = new InsertAction().execute(
|
||||
new InsertInput(EmailMessage.TABLE_NAME).withRecordEntity(emailMessage));
|
||||
Integer emailMessageId = emailInsert.getRecords().get(0).getValueInteger("id");
|
||||
|
||||
output.addValue("emailMessageId", emailMessageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Look up the "Email" activity type id. Returns null if not found.
|
||||
***************************************************************************/
|
||||
private Integer lookupEmailActivityTypeId() throws QException
|
||||
{
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(ActivityType.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Email"))));
|
||||
|
||||
if(!queryOutput.getRecords().isEmpty())
|
||||
{
|
||||
return (queryOutput.getRecords().get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process to track email engagement events (open, click, bounce, spam, reply).
|
||||
** Updates the EmailMessage record counters and flags, and may set doNotEmail
|
||||
** on the linked contact for hard bounces and spam complaints.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.EmailMessage;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep for tracking email engagement events.
|
||||
** Handles OPEN, CLICK, BOUNCE, SPAM, and REPLY event types.
|
||||
*******************************************************************************/
|
||||
public class TrackEmailEventProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_track_email_event";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Track Email Event")
|
||||
.withTableName(EmailMessage.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("track_changes"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(TrackEmailEventProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("emailMessageId", QFieldType.INTEGER).withIsRequired(true),
|
||||
new QFieldMetaData("eventType", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("bounceTypeId", QFieldType.INTEGER)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the tracking event process.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer emailMessageId = input.getValueInteger("emailMessageId");
|
||||
String eventType = input.getValueString("eventType");
|
||||
Integer bounceTypeId = input.getValueInteger("bounceTypeId");
|
||||
|
||||
//////////////////////////////////////////
|
||||
// Step 1: load the email message //
|
||||
//////////////////////////////////////////
|
||||
QRecord emailRecord = GetAction.execute(EmailMessage.TABLE_NAME, emailMessageId);
|
||||
if(emailRecord == null)
|
||||
{
|
||||
throw new QException("Email message not found: " + emailMessageId);
|
||||
}
|
||||
|
||||
EmailMessage email = new EmailMessage(emailRecord);
|
||||
|
||||
//////////////////////////////////////////
|
||||
// Step 2: handle event type //
|
||||
//////////////////////////////////////////
|
||||
QRecord updateRecord = new QRecord().withValue("id", emailMessageId);
|
||||
|
||||
switch(eventType.toUpperCase())
|
||||
{
|
||||
case "OPEN":
|
||||
{
|
||||
int currentOpenCount = email.getOpenCount() != null ? email.getOpenCount() : 0;
|
||||
updateRecord.setValue("openCount", currentOpenCount + 1);
|
||||
if(email.getFirstOpenedDate() == null)
|
||||
{
|
||||
updateRecord.setValue("firstOpenedDate", Instant.now());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "CLICK":
|
||||
{
|
||||
int currentClickCount = email.getClickCount() != null ? email.getClickCount() : 0;
|
||||
updateRecord.setValue("clickCount", currentClickCount + 1);
|
||||
if(email.getFirstClickedDate() == null)
|
||||
{
|
||||
updateRecord.setValue("firstClickedDate", Instant.now());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "BOUNCE":
|
||||
{
|
||||
updateRecord.setValue("bounceType", bounceTypeId);
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// if hard bounce, set contact doNotEmail to true //
|
||||
/////////////////////////////////////////////////////
|
||||
if(bounceTypeId != null && bounceTypeId.equals(CrmBounceType.HARD.getPossibleValueId()))
|
||||
{
|
||||
setContactDoNotEmail(email);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "SPAM":
|
||||
{
|
||||
updateRecord.setValue("isSpamComplaint", true);
|
||||
setContactDoNotEmail(email);
|
||||
break;
|
||||
}
|
||||
|
||||
case "REPLY":
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// if email is linked to a sequence enrollment, mark REPLIED //
|
||||
//////////////////////////////////////////////////////////////
|
||||
if(email.getSequenceEnrollmentId() != null)
|
||||
{
|
||||
QRecord enrollmentUpdate = new QRecord()
|
||||
.withValue("id", email.getSequenceEnrollmentId())
|
||||
.withValue("status", CrmEnrollmentStatus.REPLIED.getPossibleValueId());
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput("crm_sequence_enrollment").withRecord(enrollmentUpdate));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new QException("Unknown event type: " + eventType);
|
||||
}
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(EmailMessage.TABLE_NAME).withRecord(updateRecord));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Finds the contact linked to an email message (via activity) and sets
|
||||
** doNotEmail = true. Looks up by the toAddresses field.
|
||||
***************************************************************************/
|
||||
private void setContactDoNotEmail(EmailMessage email) throws QException
|
||||
{
|
||||
if(email.getToAddresses() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QueryOutput contactQuery = new QueryAction().execute(
|
||||
new QueryInput(Contact.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("email", QCriteriaOperator.EQUALS, email.getToAddresses()))));
|
||||
|
||||
if(!contactQuery.getRecords().isEmpty())
|
||||
{
|
||||
QRecord contactUpdate = new QRecord()
|
||||
.withValue("id", contactQuery.getRecords().get(0).getValueInteger("id"))
|
||||
.withValue("doNotEmail", true);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Contact.TABLE_NAME).withRecord(contactUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+2
-34
@@ -27,7 +27,6 @@ package com.kingsrook.qbits.crm.email.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
|
||||
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
@@ -41,49 +40,18 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaDataProducer + BackendStep to unenroll a contact from an email sequence.
|
||||
** Backend operation used by the streamed CRM actions to unenroll a contact from an email sequence.
|
||||
** Sets status to UNENROLLED and records the unenrolled date.
|
||||
*******************************************************************************/
|
||||
public class UnenrollFromSequenceProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class UnenrollFromSequenceProcess implements BackendStep
|
||||
{
|
||||
public static final String NAME = "crm_unenroll_from_sequence";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Unenroll from Sequence")
|
||||
.withIcon(new QIcon().withName("playlist_remove"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(UnenrollFromSequenceProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("enrollmentId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the unenrollment logic.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.scoring;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRule;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRuleMember;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
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.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal assignment work shared by intake and the permission-gated UI process.
|
||||
** Evaluates active rules in sort order and preserves the first matching strategy.
|
||||
*******************************************************************************/
|
||||
public final class CrmAutoAssign
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CrmAutoAssign.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmAutoAssign()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assignment outcome retained for the existing process output contract.
|
||||
*******************************************************************************/
|
||||
public record Result(String action, String assignedUserId, Integer ruleId)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assign as trusted internal work after the caller validates its entry point.
|
||||
*******************************************************************************/
|
||||
public static Result assign(CrmAssignableEntity entityType, Integer recordId) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// resolve table name //
|
||||
///////////////////////////////////////////////
|
||||
String tableName = entityType == CrmAssignableEntity.CONTACT
|
||||
? Contact.TABLE_NAME : Deal.TABLE_NAME;
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// load the record //
|
||||
///////////////////////////////////////////////
|
||||
GetInput getInput = new GetInput(tableName).withPrimaryKey(recordId);
|
||||
getInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
GetOutput recordGet = new GetAction().execute(getInput);
|
||||
|
||||
if(recordGet.getRecord() == null)
|
||||
{
|
||||
return (new Result("RECORD_NOT_FOUND", null, null));
|
||||
}
|
||||
|
||||
QRecord record = recordGet.getRecord();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query active rules for this entity type //
|
||||
///////////////////////////////////////////////
|
||||
QueryInput rulesInput = new QueryInput(AssignmentRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, entityType.getId()))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")));
|
||||
rulesInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
QueryOutput rulesQuery = new QueryAction().execute(rulesInput);
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// evaluate rules, first match wins //
|
||||
///////////////////////////////////////////////
|
||||
for(QRecord ruleRecord : rulesQuery.getRecords())
|
||||
{
|
||||
AssignmentRule rule = new AssignmentRule(ruleRecord);
|
||||
|
||||
if(evaluateCriteria(rule, record))
|
||||
{
|
||||
String assignedUserId = resolveAssignment(rule);
|
||||
|
||||
if(StringUtils.hasContent(assignedUserId))
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", recordId)
|
||||
.withValue("ownerUserId", assignedUserId);
|
||||
|
||||
UpdateInput updateInput = new UpdateInput(tableName).withRecord(updateRecord);
|
||||
updateInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
new UpdateAction().execute(updateInput);
|
||||
|
||||
return (new Result("ASSIGNED", assignedUserId, rule.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// no rules matched -- keep existing owner //
|
||||
///////////////////////////////////////////////
|
||||
return (new Result("NO_MATCH", null, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Evaluate a rule's criteria against a record. If criteriaJson is null
|
||||
** or empty, the rule matches all records.
|
||||
***************************************************************************/
|
||||
private static boolean evaluateCriteria(AssignmentRule rule, QRecord record)
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// null or empty criteria matches all //
|
||||
/////////////////////////////////////////////
|
||||
if(!StringUtils.hasContent(rule.getCriteriaJson()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// parse criteria JSON using JsonUtils //
|
||||
// format: {"fieldName":"expectedValue"} //
|
||||
/////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class);
|
||||
for(Map.Entry<String, Object> entry : criteriaMap.entrySet())
|
||||
{
|
||||
String fieldName = entry.getKey();
|
||||
String expectedValue = entry.getValue() == null ? null : String.valueOf(entry.getValue());
|
||||
String actualValue = record.getValueString(fieldName);
|
||||
|
||||
if(!Objects.equals(actualValue, expectedValue))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (true);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error parsing criteriaJson for assignment rule", e);
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the user to assign to based on the rule's strategy.
|
||||
***************************************************************************/
|
||||
private static String resolveAssignment(AssignmentRule rule) throws QException
|
||||
{
|
||||
CrmAssignStrategy strategy = CrmAssignStrategy.getById(rule.getAssignStrategy());
|
||||
|
||||
if(strategy == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
switch(strategy)
|
||||
{
|
||||
case SPECIFIC_USER:
|
||||
return (rule.getAssignToUserId());
|
||||
|
||||
case ROUND_ROBIN:
|
||||
return (resolveRoundRobin(rule));
|
||||
|
||||
case LEAST_ACTIVE:
|
||||
return (resolveLeastActive(rule));
|
||||
|
||||
default:
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Round robin: find active members, pick the one after the most recently
|
||||
** assigned (by sortOrder), wrapping around.
|
||||
***************************************************************************/
|
||||
private static String resolveRoundRobin(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// find the last assigned member //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember lastAssigned = null;
|
||||
for(AssignmentRuleMember member : members)
|
||||
{
|
||||
if(member.getLastAssignedDate() != null)
|
||||
{
|
||||
if(lastAssigned == null || member.getLastAssignedDate().isAfter(lastAssigned.getLastAssignedDate()))
|
||||
{
|
||||
lastAssigned = member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// pick next member after last assigned //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember selected;
|
||||
if(lastAssigned == null)
|
||||
{
|
||||
selected = members.get(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
int lastIndex = members.indexOf(lastAssigned);
|
||||
int nextIndex = (lastIndex + 1) % members.size();
|
||||
selected = members.get(nextIndex);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// update lastAssignedDate //
|
||||
/////////////////////////////////////////////
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Least active: find active members, pick the one with fewest owned
|
||||
** records (approximated by oldest lastAssignedDate or null).
|
||||
***************************************************************************/
|
||||
private static String resolveLeastActive(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// pick member with null lastAssignedDate, or oldest date //
|
||||
/////////////////////////////////////////////////////////////
|
||||
AssignmentRuleMember selected = members.stream()
|
||||
.min(Comparator.comparing(
|
||||
m -> m.getLastAssignedDate() != null ? m.getLastAssignedDate() : Instant.EPOCH))
|
||||
.orElse(members.get(0));
|
||||
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Query active members for a rule, ordered by sortOrder.
|
||||
***************************************************************************/
|
||||
private static List<AssignmentRuleMember> getActiveMembers(Integer ruleId) throws QException
|
||||
{
|
||||
QueryInput membersInput = new QueryInput(AssignmentRuleMember.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("assignmentRuleId", QCriteriaOperator.EQUALS, ruleId))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")));
|
||||
membersInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
QueryOutput membersQuery = new QueryAction().execute(membersInput);
|
||||
|
||||
return (membersQuery.getRecords().stream()
|
||||
.map(AssignmentRuleMember::new)
|
||||
.toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Update the lastAssignedDate on a member.
|
||||
***************************************************************************/
|
||||
private static void updateLastAssignedDate(AssignmentRuleMember member) throws QException
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", member.getId())
|
||||
.withValue("lastAssignedDate", Instant.now());
|
||||
|
||||
UpdateInput updateInput = new UpdateInput(AssignmentRuleMember.TABLE_NAME).withRecord(updateRecord);
|
||||
updateInput.withPermissionBypass("CRM assignment rules run as internal work after intake or process authorization");
|
||||
new UpdateAction().execute(updateInput);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,46 +26,12 @@
|
||||
package com.kingsrook.qbits.crm.scoring.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRule;
|
||||
import com.kingsrook.qbits.crm.scoring.model.AssignmentRuleMember;
|
||||
import com.kingsrook.qbits.crm.scoring.CrmAutoAssign;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -73,37 +39,12 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** based on configured assignment rules. Evaluates rules in sortOrder; first
|
||||
** match wins. Supports SPECIFIC_USER, ROUND_ROBIN, and LEAST_ACTIVE strategies.
|
||||
*******************************************************************************/
|
||||
public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
public class AutoAssignProcess implements BackendStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AutoAssignProcess.class);
|
||||
|
||||
public static final String NAME = "crm_auto_assign";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Auto Assign")
|
||||
.withIcon(new QIcon().withName("assignment_ind"))
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(AutoAssignProcess.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("entityType", QFieldType.STRING).withIsRequired(true),
|
||||
new QFieldMetaData("recordId", QFieldType.INTEGER).withIsRequired(true)
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the assignment logic.
|
||||
*******************************************************************************/
|
||||
@@ -132,258 +73,12 @@ public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// resolve table name //
|
||||
///////////////////////////////////////////////
|
||||
String tableName = entityType == CrmAssignableEntity.CONTACT
|
||||
? Contact.TABLE_NAME : Deal.TABLE_NAME;
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// load the record //
|
||||
///////////////////////////////////////////////
|
||||
GetOutput recordGet = new GetAction().execute(
|
||||
new GetInput(tableName).withPrimaryKey(recordId));
|
||||
|
||||
if(recordGet.getRecord() == null)
|
||||
CrmAutoAssign.Result result = CrmAutoAssign.assign(entityType, recordId);
|
||||
output.addValue("action", result.action());
|
||||
if(result.assignedUserId() != null)
|
||||
{
|
||||
output.addValue("action", "RECORD_NOT_FOUND");
|
||||
return;
|
||||
}
|
||||
|
||||
QRecord record = recordGet.getRecord();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query active rules for this entity type //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput rulesQuery = new QueryAction().execute(
|
||||
new QueryInput(AssignmentRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, entityType.getPossibleValueId()))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder"))));
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// evaluate rules, first match wins //
|
||||
///////////////////////////////////////////////
|
||||
for(QRecord ruleRecord : rulesQuery.getRecords())
|
||||
{
|
||||
AssignmentRule rule = new AssignmentRule(ruleRecord);
|
||||
|
||||
if(evaluateCriteria(rule, record))
|
||||
{
|
||||
String assignedUserId = resolveAssignment(rule);
|
||||
|
||||
if(StringUtils.hasContent(assignedUserId))
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", recordId)
|
||||
.withValue("ownerUserId", assignedUserId);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(tableName).withRecord(updateRecord));
|
||||
|
||||
output.addValue("action", "ASSIGNED");
|
||||
output.addValue("assignedUserId", assignedUserId);
|
||||
output.addValue("ruleId", rule.getId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// no rules matched -- keep existing owner //
|
||||
///////////////////////////////////////////////
|
||||
output.addValue("action", "NO_MATCH");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Evaluate a rule's criteria against a record. If criteriaJson is null
|
||||
** or empty, the rule matches all records.
|
||||
***************************************************************************/
|
||||
private boolean evaluateCriteria(AssignmentRule rule, QRecord record)
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// null or empty criteria matches all //
|
||||
/////////////////////////////////////////////
|
||||
if(!StringUtils.hasContent(rule.getCriteriaJson()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// parse criteria JSON using JsonUtils //
|
||||
// format: {"fieldName":"expectedValue"} //
|
||||
/////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class);
|
||||
for(Map.Entry<String, Object> entry : criteriaMap.entrySet())
|
||||
{
|
||||
String fieldName = entry.getKey();
|
||||
String expectedValue = entry.getValue() == null ? null : String.valueOf(entry.getValue());
|
||||
String actualValue = record.getValueString(fieldName);
|
||||
|
||||
if(!Objects.equals(actualValue, expectedValue))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (true);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error parsing criteriaJson for assignment rule", e);
|
||||
return (false);
|
||||
output.addValue("assignedUserId", result.assignedUserId());
|
||||
output.addValue("ruleId", result.ruleId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the user to assign to based on the rule's strategy.
|
||||
***************************************************************************/
|
||||
private String resolveAssignment(AssignmentRule rule) throws QException
|
||||
{
|
||||
CrmAssignStrategy strategy = CrmAssignStrategy.getById(rule.getAssignStrategy());
|
||||
|
||||
if(strategy == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
switch(strategy)
|
||||
{
|
||||
case SPECIFIC_USER:
|
||||
return (rule.getAssignToUserId());
|
||||
|
||||
case ROUND_ROBIN:
|
||||
return (resolveRoundRobin(rule));
|
||||
|
||||
case LEAST_ACTIVE:
|
||||
return (resolveLeastActive(rule));
|
||||
|
||||
default:
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Round robin: find active members, pick the one after the most recently
|
||||
** assigned (by sortOrder), wrapping around.
|
||||
***************************************************************************/
|
||||
private String resolveRoundRobin(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// find the last assigned member //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember lastAssigned = null;
|
||||
for(AssignmentRuleMember member : members)
|
||||
{
|
||||
if(member.getLastAssignedDate() != null)
|
||||
{
|
||||
if(lastAssigned == null || member.getLastAssignedDate().isAfter(lastAssigned.getLastAssignedDate()))
|
||||
{
|
||||
lastAssigned = member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// pick next member after last assigned //
|
||||
/////////////////////////////////////////////
|
||||
AssignmentRuleMember selected;
|
||||
if(lastAssigned == null)
|
||||
{
|
||||
selected = members.get(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
int lastIndex = members.indexOf(lastAssigned);
|
||||
int nextIndex = (lastIndex + 1) % members.size();
|
||||
selected = members.get(nextIndex);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// update lastAssignedDate //
|
||||
/////////////////////////////////////////////
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Least active: find active members, pick the one with fewest owned
|
||||
** records (approximated by oldest lastAssignedDate or null).
|
||||
***************************************************************************/
|
||||
private String resolveLeastActive(AssignmentRule rule) throws QException
|
||||
{
|
||||
List<AssignmentRuleMember> members = getActiveMembers(rule.getId());
|
||||
|
||||
if(members.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// pick member with null lastAssignedDate, or oldest date //
|
||||
/////////////////////////////////////////////////////////////
|
||||
AssignmentRuleMember selected = members.stream()
|
||||
.min(Comparator.comparing(
|
||||
m -> m.getLastAssignedDate() != null ? m.getLastAssignedDate() : Instant.EPOCH))
|
||||
.orElse(members.get(0));
|
||||
|
||||
updateLastAssignedDate(selected);
|
||||
|
||||
return (selected.getUserId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Query active members for a rule, ordered by sortOrder.
|
||||
***************************************************************************/
|
||||
private List<AssignmentRuleMember> getActiveMembers(Integer ruleId) throws QException
|
||||
{
|
||||
QueryOutput membersQuery = new QueryAction().execute(
|
||||
new QueryInput(AssignmentRuleMember.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("assignmentRuleId", QCriteriaOperator.EQUALS, ruleId))
|
||||
.withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder"))));
|
||||
|
||||
return (membersQuery.getRecords().stream()
|
||||
.map(AssignmentRuleMember::new)
|
||||
.toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Update the lastAssignedDate on a member.
|
||||
***************************************************************************/
|
||||
private void updateLastAssignedDate(AssignmentRuleMember member) throws QException
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", member.getId())
|
||||
.withValue("lastAssignedDate", Instant.now());
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(AssignmentRuleMember.TABLE_NAME).withRecord(updateRecord));
|
||||
}
|
||||
|
||||
}
|
||||
+39
-29
@@ -80,6 +80,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withIsHidden(true)
|
||||
.withLabel("Calculate Lead Scores")
|
||||
.withIcon(new QIcon().withName("score"))
|
||||
// Scheduling is configured by the host application via ScheduledJob records
|
||||
@@ -114,7 +115,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
///////////////////////////////////////////////////////////////
|
||||
// paginate through all contacts to avoid OOM on large CRMs //
|
||||
///////////////////////////////////////////////////////////////
|
||||
int pageSize = 500;
|
||||
int pageSize = 200;
|
||||
int updatedCount = 0;
|
||||
int totalContacts = 0;
|
||||
Integer lastId = null;
|
||||
@@ -140,38 +141,12 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
|
||||
for(QRecord contactRecord : contactsQuery.getRecords())
|
||||
{
|
||||
Contact contact = new Contact(contactRecord);
|
||||
totalContacts++;
|
||||
int newScore = 0;
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// evaluate each rule against the contact //
|
||||
///////////////////////////////////////////////
|
||||
for(LeadScoreRule rule : rules)
|
||||
if(recalculate(contactRecord, rules))
|
||||
{
|
||||
if(evaluateRule(rule, contactRecord))
|
||||
{
|
||||
newScore += rule.getScoreAdjustment();
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// update only if score changed //
|
||||
///////////////////////////////////////////////
|
||||
Integer currentScore = contact.getLeadScore() != null ? contact.getLeadScore() : 0;
|
||||
if(!Objects.equals(currentScore, newScore))
|
||||
{
|
||||
QRecord updateRecord = new QRecord()
|
||||
.withValue("id", contact.getId())
|
||||
.withValue("leadScore", newScore);
|
||||
|
||||
new UpdateAction().execute(
|
||||
new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord));
|
||||
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
lastId = contact.getId();
|
||||
lastId = contactRecord.getValueInteger("id");
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
@@ -190,6 +165,41 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recalculate only the selected contact using the same rules as the job.
|
||||
*******************************************************************************/
|
||||
public void recalculate(QRecord contact) throws QException
|
||||
{
|
||||
List<LeadScoreRule> rules = new QueryAction().execute(new QueryInput(LeadScoreRule.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")))).getRecords().stream().map(LeadScoreRule::new).toList();
|
||||
recalculate(contact, rules);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Write only changed scores; return whether a write was needed.
|
||||
*******************************************************************************/
|
||||
private boolean recalculate(QRecord contact, List<LeadScoreRule> rules) throws QException
|
||||
{
|
||||
int score = 0;
|
||||
for(LeadScoreRule rule : rules)
|
||||
{
|
||||
if(evaluateRule(rule, contact))
|
||||
{
|
||||
score += rule.getScoreAdjustment();
|
||||
}
|
||||
}
|
||||
Integer current = contact.getValueInteger("leadScore");
|
||||
if(Objects.equals(current == null ? 0 : current, score))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", contact.getValue("id")).withValue("leadScore", score)));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Evaluate a single rule against a contact record. Supports dot-notation
|
||||
** field paths for related entities (e.g., "company.industry").
|
||||
|
||||
@@ -57,7 +57,8 @@ public final class CrmPermissions
|
||||
private static final Set<String> MANAGEMENT_PROCESSES = Set.of(
|
||||
"crm_merge_contacts", "crm_merge_companies", "crm_bulk_assign_owner",
|
||||
"crm_subscribe_to_list", "crm_unsubscribe_from_list", "crm_enroll_in_sequence",
|
||||
"crm_unenroll_from_sequence", "crm_process_sequence_steps");
|
||||
"crm_unenroll_from_sequence", "crm_process_sequence_steps", "crm_assign_company_owner",
|
||||
"crm_transfer_ownership", "crm_activate_sequence", "crm_pause_sequence");
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.security;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Purpose-bound HMAC-SHA256 links. Payloads contain identifiers, expiry and a nonce, never personal data.
|
||||
*******************************************************************************/
|
||||
public class CrmTokens
|
||||
{
|
||||
private final CrmQBitConfig config;
|
||||
private final Clock clock;
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the current UTC clock.
|
||||
*******************************************************************************/
|
||||
public CrmTokens(CrmQBitConfig config) throws QUserFacingException
|
||||
{
|
||||
this(config, Clock.systemUTC());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit clock permits deterministic expiry verification.
|
||||
*******************************************************************************/
|
||||
public CrmTokens(CrmQBitConfig config, Clock clock) throws QUserFacingException
|
||||
{
|
||||
if(config.getTokenSecret() == null || config.getTokenSecret().isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("CRM intake requires CrmQBitConfig.withTokenSecret(String)."));
|
||||
}
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue a link valid for ninety days. Confirm ids identify members; unsubscribe ids identify contacts.
|
||||
*******************************************************************************/
|
||||
public String issue(String purpose, Integer id, Integer listId) throws QUserFacingException
|
||||
{
|
||||
if(!("confirm".equals(purpose) || "unsubscribe".equals(purpose)) || id == null || id <= 0
|
||||
|| (listId != null && listId <= 0) || ("confirm".equals(purpose) && listId == null))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
String payload = purpose + ":" + id + ":" + (listId == null ? "" : listId) + ":"
|
||||
+ clock.instant().plus(Duration.ofDays(90)).getEpochSecond() + ":" + UUID.randomUUID();
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return (encoded + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sign(encoded)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify signature in constant time before parsing purpose or identifiers.
|
||||
*******************************************************************************/
|
||||
public Claims verify(String token, String purpose) throws QUserFacingException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(token == null || token.length() > 1024)
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
String[] parts = token.split("\\.", -1);
|
||||
if(parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64.getUrlDecoder().decode(parts[1])))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
String[] values = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8).split(":", -1);
|
||||
if(values.length != 5 || !purpose.equals(values[0]))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
Integer id = Integer.valueOf(values[1]);
|
||||
Integer listId = values[2].isEmpty() ? null : Integer.valueOf(values[2]);
|
||||
Instant expiry = Instant.ofEpochSecond(Long.parseLong(values[3]));
|
||||
if(id <= 0 || (listId != null && listId <= 0) || ("confirm".equals(purpose) && listId == null) || !expiry.isAfter(clock.instant()))
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
return (new Claims(purpose, id, listId, expiry));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hash the entire confirmation token before persistence.
|
||||
*******************************************************************************/
|
||||
public static String hash(String token)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a link from the host's explicitly configured public origin/path.
|
||||
*******************************************************************************/
|
||||
public String url(String purpose, String token) throws QUserFacingException
|
||||
{
|
||||
String base = config.getPublicBaseUrl();
|
||||
if(base == null || !base.matches("https?://[^\\s?#]+"))
|
||||
{
|
||||
throw (new QUserFacingException("CRM links require CrmQBitConfig.withPublicBaseUrl(String)."));
|
||||
}
|
||||
return (base.replaceAll("/+$", "") + "/qqq/hooks/crm/" + purpose + "/" + token);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compute an authentication code without exposing the secret.
|
||||
*******************************************************************************/
|
||||
private byte[] sign(String payload) throws QUserFacingException
|
||||
{
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(config.getTokenSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return (mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(java.security.GeneralSecurityException e)
|
||||
{
|
||||
throw (new QUserFacingException("CRM token signing is unavailable."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately does not reveal the token or validation detail.
|
||||
*******************************************************************************/
|
||||
private static QUserFacingException invalid()
|
||||
{
|
||||
return (new QUserFacingException("This link is invalid or has expired. Please request a new link."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated token payload.
|
||||
*******************************************************************************/
|
||||
public record Claims(String purpose, Integer id, Integer listId, Instant expiresAt)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.crm.webhook;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.api.CrmApi;
|
||||
import com.kingsrook.qbits.crm.api.CrmIntakeApi;
|
||||
import com.kingsrook.qbits.crm.security.CrmTokens;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
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.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed machine routes outside the UI API. GET never mutates records, and pages never display personal data or tokens.
|
||||
*******************************************************************************/
|
||||
public class CrmHookRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private QInstance instance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Register once when the host has Javalin middleware.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance);
|
||||
if(CollectionUtils.nonNullList(metadata.getAdditionalRouteProviderReferences()).stream()
|
||||
.noneMatch(reference -> reference != null && CrmHookRouteProvider.class.getName().equals(reference.getName())))
|
||||
{
|
||||
metadata.withAdditionalRouteProviderReference(new QCodeReference(CrmHookRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get("/qqq/hooks/crm/confirm/{token}", context -> handle(context, "confirm", false));
|
||||
config.routes.post("/qqq/hooks/crm/confirm/{token}", context -> handle(context, "confirm", true));
|
||||
config.routes.get("/qqq/hooks/crm/unsubscribe/{token}", context -> handle(context, "unsubscribe", false));
|
||||
config.routes.post("/qqq/hooks/crm/unsubscribe/{token}", context -> handle(context, "unsubscribe", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The signed token authorizes only the identified operation; restore the caller context afterward.
|
||||
*******************************************************************************/
|
||||
private void handle(Context context, String purpose, boolean post)
|
||||
{
|
||||
CapturedContext previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer")
|
||||
.header("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'");
|
||||
String token = context.pathParam("token");
|
||||
new CrmTokens(CrmApi.config()).verify(token, purpose);
|
||||
if(post)
|
||||
{
|
||||
CrmIntakeApi api = new CrmIntakeApi();
|
||||
if("confirm".equals(purpose))
|
||||
{
|
||||
api.confirm(token);
|
||||
}
|
||||
else
|
||||
{
|
||||
api.unsubscribe(token);
|
||||
}
|
||||
page(context, 200, "Preference saved", "Your email preference has been saved.", null);
|
||||
}
|
||||
else
|
||||
{
|
||||
String label = "confirm".equals(purpose) ? "Confirm subscription" : "Unsubscribe";
|
||||
page(context, 200, label, "Choose the button below to save your email preference.", label);
|
||||
}
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
page(context, 400, "Link unavailable", "This link is invalid or has expired. Please request a new link.", null);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
page(context, 503, "Please try again", "We could not save your preference. Please try again later.", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All content is fixed text; the browser posts to its current URL without embedding the token.
|
||||
*******************************************************************************/
|
||||
private void page(Context context, int status, String title, String message, String button)
|
||||
{
|
||||
context.status(status).contentType("text/html; charset=utf-8").result("<!doctype html><html lang=\"en\"><head>"
|
||||
+ "<meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>" + title
|
||||
+ "</title></head><body><main><h1>" + title + "</h1><p>" + message + "</p>"
|
||||
+ (button == null ? "" : "<form method=\"post\"><button type=\"submit\">" + button + "</button></form>")
|
||||
+ "</main></body></html>");
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Activity Count widget.
|
||||
**
|
||||
** Queries activities created today and groups them by activityTypeId to
|
||||
** produce counts. Returns a MultiStatisticsData with one entry per known
|
||||
** type: Call, Email, Meeting.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qbits.crm.activities.model.ActivityType;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders activity counts by type for today as a multi-statistics widget.
|
||||
*******************************************************************************/
|
||||
public class CrmActivityCountRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// determine start-of-day in the system's time zone //
|
||||
////////////////////////////////////////////////////////
|
||||
ZoneId zoneId = ZoneId.systemDefault();
|
||||
ZonedDateTime startOfDay = LocalDate.now(zoneId).atStartOfDay(zoneId);
|
||||
Instant todayStart = startOfDay.toInstant();
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// query all activity types for labeling //
|
||||
////////////////////////////////////////////////////////////////
|
||||
Map<Integer, String> typeNameMap = new HashMap<>();
|
||||
QueryOutput typeOutput = new QueryAction().execute(
|
||||
new QueryInput(ActivityType.TABLE_NAME));
|
||||
for(QRecord typeRecord : typeOutput.getRecords())
|
||||
{
|
||||
typeNameMap.put(typeRecord.getValueInteger("id"), typeRecord.getValueString("name"));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// query activities created today //
|
||||
////////////////////////////////////////////////////////////
|
||||
QueryOutput activityOutput = new QueryAction().execute(
|
||||
new QueryInput(Activity.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, todayStart))));
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// count by activityTypeId //
|
||||
////////////////////////////////////////////////////////////
|
||||
Map<Integer, Integer> countByType = new HashMap<>();
|
||||
for(QRecord record : activityOutput.getRecords())
|
||||
{
|
||||
Integer typeId = record.getValueInteger("activityTypeId");
|
||||
if(typeId != null)
|
||||
{
|
||||
countByType.merge(typeId, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// build statistics group data //
|
||||
////////////////////////////////////////////////////////////
|
||||
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
|
||||
for(Map.Entry<Integer, Integer> entry : countByType.entrySet())
|
||||
{
|
||||
String typeName = typeNameMap.getOrDefault(entry.getKey(), "Type " + entry.getKey());
|
||||
groups.add(new MultiStatisticsData.StatisticsGroupData()
|
||||
.withHeader(typeName)
|
||||
.withSubheader("today")
|
||||
.withStatisticList(List.of(
|
||||
new MultiStatisticsData.StatisticsGroupData.Statistic(typeName, entry.getValue(), null)
|
||||
)));
|
||||
}
|
||||
|
||||
MultiStatisticsData data = new MultiStatisticsData("Activity Counts (Today)", groups);
|
||||
|
||||
return (new RenderWidgetOutput(data));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Activity Count widget.
|
||||
**
|
||||
** Provides a MULTI_STATISTICS view showing counts of activities by type for
|
||||
** the current user today: calls, emails, meetings.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_activity_count widget (MULTI_STATISTICS type).
|
||||
*******************************************************************************/
|
||||
public class CrmActivityCountWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_activity_count";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.MULTI_STATISTICS.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Activity Counts (Today)")
|
||||
.withTooltip("Activity counts by type for today")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new QIcon("analytics"))
|
||||
.withCodeReference(new QCodeReference(CrmActivityCountRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Activity Feed widget.
|
||||
**
|
||||
** Queries the most recent 50 activities (by createDate descending) and returns
|
||||
** a table with columns: subject, activityType, contact, company, date.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the activity feed as a table widget showing recent activities.
|
||||
*******************************************************************************/
|
||||
public class CrmActivityFeedRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// query recent activities, ordered by createDate desc //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryInput queryInput = new QueryInput(Activity.TABLE_NAME);
|
||||
queryInput.setFilter(new QQueryFilter()
|
||||
.withOrderBy(new QFilterOrderBy("createDate", false))
|
||||
.withLimit(50));
|
||||
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Subject", "subject", "2fr", null),
|
||||
new TableData.Column("html", "Activity Type", "activityType", "1fr", null),
|
||||
new TableData.Column("html", "Contact", "contact", "1fr", null),
|
||||
new TableData.Column("html", "Company", "company", "1fr", null),
|
||||
new TableData.Column("html", "Date", "date", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
String subject = record.getValueString("subject");
|
||||
if(subject == null)
|
||||
{
|
||||
subject = "";
|
||||
}
|
||||
|
||||
Integer activityTypeId = record.getValueInteger("activityTypeId");
|
||||
String activityTypeDisplay = activityTypeId != null ? String.valueOf(activityTypeId) : "-";
|
||||
|
||||
Integer contactId = record.getValueInteger("contactId");
|
||||
String contactDisplay = contactId != null ? String.valueOf(contactId) : "-";
|
||||
|
||||
Integer companyId = record.getValueInteger("companyId");
|
||||
String companyDisplay = companyId != null ? String.valueOf(companyId) : "-";
|
||||
|
||||
Instant createDate = record.getValueInstant("createDate");
|
||||
String dateDisplay = createDate != null ? DATE_FORMATTER.format(createDate) : "-";
|
||||
|
||||
rows.add(MapBuilder.of(
|
||||
"subject", subject,
|
||||
"activityType", activityTypeDisplay,
|
||||
"contact", contactDisplay,
|
||||
"company", companyDisplay,
|
||||
"date", dateDisplay
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(50)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Activity Feed widget.
|
||||
**
|
||||
** Provides a TABLE view showing the most recent 50 activities across the CRM
|
||||
** with columns: subject, activityType, contact, company, date.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_activity_feed widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmActivityFeedWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_activity_feed";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Activity Feed")
|
||||
.withTooltip("Recent CRM activities")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("event_note"))
|
||||
.withCodeReference(new QCodeReference(CrmActivityFeedRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the Average Deal Size widget.
|
||||
**
|
||||
** Queries deals where actualCloseDate is set and stage is closedWon.
|
||||
** Computes average amountInBaseCurrency and returns as a single-stat table.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders average deal size as a single-stat table widget.
|
||||
*******************************************************************************/
|
||||
public class CrmAvgDealSizeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// find closedWon stage IDs //
|
||||
///////////////////////////////////////////////////////////
|
||||
Set<Integer> closedWonStageIds = new HashSet<>();
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
|
||||
|
||||
for(QRecord record : stageOutput.getRecords())
|
||||
{
|
||||
closedWonStageIds.add(record.getValueInteger("id"));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// query deals with actualCloseDate set //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput dealOutput = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK))));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// compute average for closedWon deals //
|
||||
///////////////////////////////////////////////////////////
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
int count = 0;
|
||||
|
||||
for(QRecord record : dealOutput.getRecords())
|
||||
{
|
||||
Integer stageId = record.getValueInteger("pipelineStageId");
|
||||
if(closedWonStageIds.contains(stageId))
|
||||
{
|
||||
BigDecimal amount = record.getValueBigDecimal("amountInBaseCurrency");
|
||||
if(amount == null)
|
||||
{
|
||||
amount = record.getValueBigDecimal("amount");
|
||||
}
|
||||
if(amount != null)
|
||||
{
|
||||
totalAmount = totalAmount.add(amount);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BigDecimal avgDealSize = count > 0
|
||||
? totalAmount.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
///////////////////////////
|
||||
// build table output //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Metric", "metric", "1fr", null),
|
||||
new TableData.Column("html", "Value", "value", "1fr", "right")
|
||||
);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(MapBuilder.of(
|
||||
"metric", "Average Deal Size",
|
||||
"value", "$" + avgDealSize.toPlainString()
|
||||
));
|
||||
rows.add(MapBuilder.of(
|
||||
"metric", "Closed Won Deals",
|
||||
"value", String.valueOf(count)
|
||||
));
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(10)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the Average Deal Size widget.
|
||||
**
|
||||
** Shows the average amount of closed-won deals.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_avg_deal_size widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmAvgDealSizeWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_avg_deal_size";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(3)
|
||||
.withIsCard(true)
|
||||
.withLabel("Average Deal Size")
|
||||
.withTooltip("Average amount of closed-won deals")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("bar_chart"))
|
||||
.withCodeReference(new QCodeReference(CrmAvgDealSizeRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,109 +19,60 @@
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Contact Timeline widget.
|
||||
**
|
||||
** Queries activities for a specific contactId (from widget input) and returns
|
||||
** a StepperData with each activity as a chronological step. Activities are
|
||||
** ordered by createDate ascending.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.activities.model.Activity;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StepperData;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders a contact's activity timeline as a stepper widget.
|
||||
** A contact's latest fifty recorded events across activities, sent messages,
|
||||
** web leads and consent. Each source is bounded before merging by create date.
|
||||
*******************************************************************************/
|
||||
public class CrmContactTimelineRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Record sections supply id; contactId remains useful for embedded hosts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String contactIdParam = input.getQueryParams().get("contactId");
|
||||
|
||||
if(!StringUtils.hasContent(contactIdParam))
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
String id = input.getQueryParams().getOrDefault("id", input.getQueryParams().get("contactId"));
|
||||
if(id != null && !id.isBlank() && GetAction.execute("crm_contact", Integer.valueOf(id)) != null)
|
||||
{
|
||||
return (new RenderWidgetOutput(new StepperData("Contact Timeline", 0, List.of())));
|
||||
}
|
||||
|
||||
Integer contactId = ValueUtils.getValueAsInteger(contactIdParam);
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// query activities for this contact, ordered chronologically //
|
||||
//////////////////////////////////////////////////////////////
|
||||
QueryInput queryInput = new QueryInput(Activity.TABLE_NAME);
|
||||
queryInput.setFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId))
|
||||
.withOrderBy(new QFilterOrderBy("createDate", true)));
|
||||
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
|
||||
////////////////////////////////////
|
||||
// build steps from activities //
|
||||
////////////////////////////////////
|
||||
List<StepperData.Step> steps = new ArrayList<>();
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
String subject = record.getValueString("subject");
|
||||
if(subject == null)
|
||||
List<QRecord> events = new ArrayList<>();
|
||||
for(String table : List.of("crm_activity", "crm_email_message", "crm_form_submission", "crm_consent_record"))
|
||||
{
|
||||
subject = "Activity";
|
||||
for(QRecord record : CrmHomeRenderer.query(table, CrmHomeRenderer.equalsFilter("contactId", Integer.valueOf(id)), "createDate", false, 50))
|
||||
{
|
||||
record.setValue("timelineTable", table);
|
||||
events.add(record);
|
||||
}
|
||||
}
|
||||
events.sort(Comparator.comparing((QRecord record) -> Objects.requireNonNullElse(record.getValueInstant("createDate"), Instant.EPOCH)).reversed()
|
||||
.thenComparing(record -> record.getValueString("timelineTable")).thenComparing(record -> record.getValueInteger("id")));
|
||||
for(QRecord record : events.stream().limit(50).toList())
|
||||
{
|
||||
String table = record.getValueString("timelineTable");
|
||||
String kind = Map.of("crm_activity", "Activity", "crm_email_message", "Email message",
|
||||
"crm_form_submission", "Web lead", "crm_consent_record", "Consent record").get(table);
|
||||
String detail = Objects.toString(record.getValue("subject"), Objects.toString(record.getValue("email"), kind));
|
||||
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), record.getValue("createDate"), kind, detail));
|
||||
}
|
||||
|
||||
Instant createDate = record.getValueInstant("createDate");
|
||||
String dateStr = createDate != null ? DATE_FORMATTER.format(createDate) : "";
|
||||
|
||||
String description = record.getValueString("description");
|
||||
String linkText = StringUtils.hasContent(description)
|
||||
? description.substring(0, Math.min(description.length(), 100))
|
||||
: dateStr;
|
||||
|
||||
steps.add(new StepperData.Step()
|
||||
.withLabel(subject)
|
||||
.withLinkText(linkText));
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// set activeStep to the last step (most recent activity) //
|
||||
//////////////////////////////////////////////////////////////
|
||||
Integer activeStep = steps.isEmpty() ? 0 : steps.size() - 1;
|
||||
|
||||
StepperData stepperData = new StepperData("Contact Timeline", activeStep, steps);
|
||||
|
||||
return (new RenderWidgetOutput(stepperData));
|
||||
return (new RenderWidgetOutput(CrmHomeRenderer.table(rows, "Recorded", "Kind", "Details")));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,13 +22,14 @@
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Contact Timeline widget.
|
||||
**
|
||||
** Provides a STEPPER view showing a chronological timeline of activities for
|
||||
** a specific contact. The contactId is passed as a widget input parameter.
|
||||
** Provides a standard table of activities, messages, web leads and consent for
|
||||
** the contact selected by the record screen id parameter.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
@@ -39,7 +40,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_contact_timeline widget (STEPPER type).
|
||||
** Produces metadata for the crm_contact_timeline widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmContactTimelineWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
@@ -55,8 +56,9 @@ public class CrmContactTimelineWidgetProducer extends MetaDataProducer<QWidgetMe
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.STEPPER.getType())
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(12)
|
||||
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
|
||||
.withIsCard(true)
|
||||
.withLabel("Contact Timeline")
|
||||
.withTooltip("Chronological activity timeline for this contact")
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Deal Forecast widget.
|
||||
**
|
||||
** Queries open deals (not in closed stages) and groups them by expected close
|
||||
** month. Returns a table with columns: month, dealCount, totalAmount,
|
||||
** totalWeightedAmount.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the deal forecast as a table widget showing open deal aggregates
|
||||
** grouped by expected close month.
|
||||
*******************************************************************************/
|
||||
public class CrmDealForecastRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// find all closed stage IDs to exclude //
|
||||
///////////////////////////////////////////////////////////////
|
||||
Set<Integer> closedStageIds = findClosedStageIds();
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// query all deals that are NOT in closed stages //
|
||||
///////////////////////////////////////////////////////////////
|
||||
QQueryFilter filter = new QQueryFilter();
|
||||
if(!closedStageIds.isEmpty())
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.NOT_IN, new ArrayList<>(closedStageIds)));
|
||||
}
|
||||
|
||||
QueryOutput dealOutput = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME).withFilter(filter));
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// aggregate deals by expected close month //
|
||||
//////////////////////////////////////////////////////////
|
||||
Map<YearMonth, Integer> countByMonth = new TreeMap<>();
|
||||
Map<YearMonth, BigDecimal> amountByMonth = new TreeMap<>();
|
||||
Map<YearMonth, BigDecimal> weightedByMonth = new TreeMap<>();
|
||||
|
||||
for(QRecord record : dealOutput.getRecords())
|
||||
{
|
||||
LocalDate expectedCloseDate = record.getValueLocalDate("expectedCloseDate");
|
||||
if(expectedCloseDate == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
YearMonth yearMonth = YearMonth.from(expectedCloseDate);
|
||||
|
||||
countByMonth.merge(yearMonth, 1, Integer::sum);
|
||||
|
||||
BigDecimal amount = record.getValueBigDecimal("amount");
|
||||
if(amount != null)
|
||||
{
|
||||
amountByMonth.merge(yearMonth, amount, BigDecimal::add);
|
||||
}
|
||||
|
||||
BigDecimal weighted = record.getValueBigDecimal("weightedAmount");
|
||||
if(weighted != null)
|
||||
{
|
||||
weightedByMonth.merge(yearMonth, weighted, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Month", "month", "1fr", null),
|
||||
new TableData.Column("html", "Deal Count", "dealCount", "1fr", "right"),
|
||||
new TableData.Column("html", "Total Amount", "totalAmount", "1fr", "right"),
|
||||
new TableData.Column("html", "Weighted Amount", "totalWeightedAmount", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(YearMonth yearMonth : countByMonth.keySet())
|
||||
{
|
||||
rows.add(MapBuilder.of(
|
||||
"month", yearMonth.format(MONTH_FORMAT),
|
||||
"dealCount", countByMonth.getOrDefault(yearMonth, 0),
|
||||
"totalAmount", amountByMonth.getOrDefault(yearMonth, BigDecimal.ZERO).toPlainString(),
|
||||
"totalWeightedAmount", weightedByMonth.getOrDefault(yearMonth, BigDecimal.ZERO).toPlainString()
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(50)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Find all pipeline stage IDs that are closed (won or lost).
|
||||
***************************************************************************/
|
||||
private Set<Integer> findClosedStageIds() throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true));
|
||||
|
||||
QQueryFilter lostFilter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.EQUALS, true));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// query stages that are closed won OR closed lost //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
QueryOutput wonOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME).withFilter(filter));
|
||||
|
||||
QueryOutput lostOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME).withFilter(lostFilter));
|
||||
|
||||
Set<Integer> closedIds = wonOutput.getRecords().stream()
|
||||
.map(r -> r.getValueInteger("id"))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
lostOutput.getRecords().stream()
|
||||
.map(r -> r.getValueInteger("id"))
|
||||
.forEach(closedIds::add);
|
||||
|
||||
return (closedIds);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Deal Forecast widget.
|
||||
**
|
||||
** Provides a TABLE view showing open deals grouped by expected close month
|
||||
** with counts, total amounts, and weighted amounts.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_deal_forecast widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmDealForecastWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_deal_forecast";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Deal Forecast")
|
||||
.withTooltip("Open deals grouped by expected close month")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("trending_up"))
|
||||
.withCodeReference(new QCodeReference(CrmDealForecastRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hide Goals in per-session frontend metadata unless a current goal is visible.
|
||||
** This is evaluated at request time, never while building shared app metadata.
|
||||
*******************************************************************************/
|
||||
public class CrmGoalsVisibility implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve crm.hasAccess and check existence through a bounded scoped query.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS))
|
||||
{
|
||||
throw (new QPermissionDeniedException("CRM access is required."));
|
||||
}
|
||||
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today))
|
||||
.withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today));
|
||||
try
|
||||
{
|
||||
if(!CrmHomeRenderer.query("crm_sales_goal", filter, "id", true, 1).isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Current goals are not available.", e));
|
||||
}
|
||||
throw (new QPermissionDeniedException("No goals for the current period."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormType;
|
||||
import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ParentWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Daily CRM views. Core actions retain the current session and record locks.
|
||||
** Monetary totals use base currency; weighted amounts are grouped by currency
|
||||
** before conversion. No query loads all deals, contacts or submissions.
|
||||
*******************************************************************************/
|
||||
public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
private static final String DEAL = "crm_deal";
|
||||
private static final String STAGE = "crm_pipeline_stage";
|
||||
private static final Aggregate COUNT = new Aggregate("id", AggregateOperator.COUNT);
|
||||
private static final Aggregate AMOUNT = new Aggregate("amountInBaseCurrency", AggregateOperator.SUM);
|
||||
private static final Aggregate WEIGHTED = new Aggregate("weightedAmount", AggregateOperator.SUM);
|
||||
private static final GroupBy CURRENCY = new GroupBy(QFieldType.STRING, "currencyCode");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render only the requested home component using UTC reporting boundaries.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
switch(input.getWidgetMetaData().getName())
|
||||
{
|
||||
case "crm_today":
|
||||
return (new RenderWidgetOutput(new ParentWidgetData().withChildWidgetNameList(CrmHomeWidgets.TODAY).withLabel("Today")));
|
||||
case "crm_open_pipeline":
|
||||
return (new RenderWidgetOutput(statistic(sum(DEAL, dealStatus(false), AMOUNT), true)));
|
||||
case "crm_won_month":
|
||||
return (new RenderWidgetOutput(statistic(sum(DEAL, period(dealStatus(true), "actualCloseDate", today.withDayOfMonth(1), today.plusDays(1)), AMOUNT), true)));
|
||||
case "crm_new_contacts":
|
||||
return (new RenderWidgetOutput(statistic(countSince("crm_contact", 30), false)));
|
||||
case "crm_new_web_leads":
|
||||
return (new RenderWidgetOutput(statistic(countSince("crm_form_submission", 7), false)));
|
||||
case "crm_overdue_activities":
|
||||
return (new RenderWidgetOutput(overdue()));
|
||||
case "crm_pipeline_summary":
|
||||
return (new RenderWidgetOutput(pipeline(input)));
|
||||
case "crm_deal_forecast":
|
||||
return (new RenderWidgetOutput(forecast(today)));
|
||||
case "crm_rotting_deals":
|
||||
return (new RenderWidgetOutput(rotting()));
|
||||
case "crm_recent_web_leads":
|
||||
return (new RenderWidgetOutput(webLeads()));
|
||||
case "crm_goals":
|
||||
return (new RenderWidgetOutput(goals(today)));
|
||||
case "crm_sales_leaderboard":
|
||||
return (new RenderWidgetOutput(leaderboard(today)));
|
||||
default:
|
||||
throw new QException("Unknown CRM home widget: " + input.getWidgetMetaData().getName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve decimals and large counts in the standard statistics renderer.
|
||||
*******************************************************************************/
|
||||
private StatisticsData statistic(BigDecimal value, boolean currency)
|
||||
{
|
||||
return (new StatisticsData().withCount(value).withCountContext(currency ? "base currency" : null));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rolling counts include the present instant and exclude future records.
|
||||
*******************************************************************************/
|
||||
private BigDecimal countSince(String table, int days) throws QException
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
return (sum(table, period(new QQueryFilter(), "createDate", now.minus(days, ChronoUnit.DAYS), now), COUNT));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unassigned activity is never treated as belonging to an anonymous user.
|
||||
*******************************************************************************/
|
||||
private StatisticsData overdue() throws QException
|
||||
{
|
||||
String user = QContext.getQSession().getIdReference();
|
||||
if(user == null || user.isBlank())
|
||||
{
|
||||
return (statistic(BigDecimal.ZERO, false));
|
||||
}
|
||||
QQueryFilter filter = equalsFilter("assignedToUserId", user)
|
||||
.withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
|
||||
.withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now()));
|
||||
return (statistic(sum("crm_activity", filter, COUNT), false));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open and won stages are resolved through the current session as well.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter dealStatus(boolean won) throws QException
|
||||
{
|
||||
QQueryFilter filter = won ? equalsFilter("isClosedWon", true) : new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
|
||||
.withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true));
|
||||
List<Integer> ids = allStages(filter).stream().map(row -> row.getValueInteger("id")).toList();
|
||||
return (new QQueryFilter().withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, ids)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stage dictionaries are paged; transactional facts are aggregated in SQL.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> allStages(QQueryFilter filter) throws QException
|
||||
{
|
||||
return (dictionary(STAGE, filter));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read small settings dictionaries in deterministic keyset pages.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> dictionary(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> stages = new ArrayList<>();
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter page = filter.clone().withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
|
||||
List<QRecord> records = query(table, page, "id", true, 200);
|
||||
stages.addAll(records);
|
||||
if(records.size() < 200)
|
||||
{
|
||||
return (stages);
|
||||
}
|
||||
lastId = records.getLast().getValueInteger("id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stage and deal count share one column, keeping both monetary columns.
|
||||
*******************************************************************************/
|
||||
private TableData pipeline(RenderWidgetInput input) throws QException
|
||||
{
|
||||
List<QRecord> pipelines = dictionary("crm_pipeline", equalsFilter("isActive", true));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
String selected = pipelines.isEmpty() ? null : pipelines.getFirst().getValueString("id");
|
||||
String requested = input.getQueryParams().get("pipelineId");
|
||||
if(pipelines.stream().anyMatch(pipeline -> pipeline.getValueString("id").equals(requested)))
|
||||
{
|
||||
selected = requested;
|
||||
}
|
||||
if(!pipelines.isEmpty())
|
||||
{
|
||||
Integer pipelineId = Integer.valueOf(selected);
|
||||
List<QRecord> stages = allStages(equalsFilter("pipelineId", pipelineId));
|
||||
stages.sort(Comparator.comparing(row -> Objects.requireNonNullElse(row.getValueInteger("sortOrder"), 0)));
|
||||
GroupBy stageGroup = new GroupBy(QFieldType.INTEGER, "pipelineStageId");
|
||||
Map<Serializable, BigDecimal[]> totals = groupedTotals(equalsFilter("pipelineId", pipelineId), stageGroup);
|
||||
for(QRecord stage : stages)
|
||||
{
|
||||
BigDecimal[] values = totals.getOrDefault(stage.getValue("id"), new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
|
||||
String href = AiLinkBuilder.recordHref(STAGE, stage.getValue("id"));
|
||||
rows.add(row(href, stage.getValueString("name") + " (" + values[0].toPlainString() + " deals)", values[1], values[2]));
|
||||
}
|
||||
}
|
||||
TableData data = table(rows, "Stage / deals", "Amount (base)", "Weighted amount (base)");
|
||||
if(pipelines.size() > 1)
|
||||
{
|
||||
data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline"))
|
||||
.withDropdownDefaultValueList(List.of(selected))
|
||||
.withDropdownDataList(List.of(pipelines.stream().map(pipeline -> Map.of("id", pipeline.getValueString("id"),
|
||||
"label", Objects.toString(pipeline.getValue("name"), "Pipeline " + pipeline.getValueString("id")))).toList()));
|
||||
}
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Six calendar buckets, including the current month, with empty months kept.
|
||||
*******************************************************************************/
|
||||
private TableData forecast(LocalDate today) throws QException
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(int month = 0; month < 6; month++)
|
||||
{
|
||||
LocalDate start = today.withDayOfMonth(1).plusMonths(month);
|
||||
QQueryFilter filter = period(dealStatus(false), "expectedCloseDate", start, start.plusMonths(1));
|
||||
BigDecimal[] totals = totals(filter);
|
||||
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0], totals[2]));
|
||||
}
|
||||
return (table(rows, "Month", "Deals", "Weighted amount (base)"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Limit the candidates in each stage in SQL, then select the oldest ten.
|
||||
*******************************************************************************/
|
||||
private TableData rotting() throws QException
|
||||
{
|
||||
List<QRecord> candidates = new ArrayList<>();
|
||||
Map<Integer, String> stages = new LinkedHashMap<>();
|
||||
Instant now = Instant.now();
|
||||
QQueryFilter stageFilter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
|
||||
.withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true))
|
||||
.withCriteria(new QFilterCriteria("rotDays", QCriteriaOperator.GREATER_THAN, 0));
|
||||
for(QRecord stage : allStages(stageFilter))
|
||||
{
|
||||
Integer id = stage.getValueInteger("id");
|
||||
stages.put(id, stage.getValueString("name"));
|
||||
QQueryFilter filter = equalsFilter("pipelineStageId", id)
|
||||
.withCriteria(new QFilterCriteria("stageEnteredDate", QCriteriaOperator.LESS_THAN, now.minus(stage.getValueInteger("rotDays"), ChronoUnit.DAYS)));
|
||||
candidates.addAll(query(DEAL, filter, "stageEnteredDate", true, 10));
|
||||
}
|
||||
candidates.sort(Comparator.comparing(row -> row.getValueInstant("stageEnteredDate")));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord deal : candidates.stream().limit(10).toList())
|
||||
{
|
||||
rows.add(row(AiLinkBuilder.recordHref(DEAL, deal.getValue("id")), deal.getValueString("name"),
|
||||
stages.get(deal.getValueInteger("pipelineStageId")), ChronoUnit.DAYS.between(deal.getValueInstant("stageEnteredDate"), now)));
|
||||
}
|
||||
return (table(rows, "Deal", "Stage", "Days in stage"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest ten submissions, with escaped labels and links on every cell.
|
||||
*******************************************************************************/
|
||||
private TableData webLeads() throws QException
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord lead : query("crm_form_submission", new QQueryFilter(), "createDate", false, 10))
|
||||
{
|
||||
String name = (Objects.toString(lead.getValue("firstName"), "") + " " + Objects.toString(lead.getValue("lastName"), "")).trim();
|
||||
if(name.isBlank())
|
||||
{
|
||||
name = Objects.toString(lead.getValue("email"), "Web lead");
|
||||
}
|
||||
String form = "Unknown";
|
||||
for(CrmFormType value : CrmFormType.values())
|
||||
{
|
||||
if(value.getId().equals(lead.getValueInteger("formType")))
|
||||
{
|
||||
form = value.getLabel();
|
||||
}
|
||||
}
|
||||
String status = "New";
|
||||
for(CrmFormConversionStatus value : CrmFormConversionStatus.values())
|
||||
{
|
||||
if(value.getId().equals(lead.getValueInteger("conversionStatus")))
|
||||
{
|
||||
status = value.getLabel();
|
||||
}
|
||||
}
|
||||
rows.add(row(AiLinkBuilder.recordHref("crm_form_submission", lead.getValue("id")), name, form, status));
|
||||
}
|
||||
return (table(rows, "Name or email", "Form", "Status"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current goals only; metadata visibility hides the widget when none exist.
|
||||
*******************************************************************************/
|
||||
private TableData goals(LocalDate today) throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today))
|
||||
.withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord goal : query("crm_sales_goal", filter, "id", true, 50))
|
||||
{
|
||||
QQueryFilter deals = period(dealStatus(true), "actualCloseDate", goal.getValueLocalDate("periodStart"), goal.getValueLocalDate("periodEnd").plusDays(1));
|
||||
deals.withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, goal.getValue("userId")));
|
||||
if(goal.getValue("pipelineId") != null)
|
||||
{
|
||||
deals.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, goal.getValue("pipelineId")));
|
||||
}
|
||||
BigDecimal actual = sum(DEAL, deals, AMOUNT);
|
||||
BigDecimal target = CrmCurrencyUtils.convertToBaseCurrency(goal.getValueBigDecimal("targetAmount"), goal.getValueString("currencyCode"));
|
||||
String attainment = target != null && target.signum() > 0 ? actual.multiply(BigDecimal.valueOf(100)).divide(target, 1, RoundingMode.HALF_UP) + "%" : "No target";
|
||||
rows.add(row(AiLinkBuilder.recordHref("crm_sales_goal", goal.getValue("id")),
|
||||
goal.getValueString("userId") + " (" + attainment + ")", target, actual));
|
||||
}
|
||||
TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)");
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quarter-to-date won revenue grouped by owner, linking to matching deals.
|
||||
*******************************************************************************/
|
||||
private TableData leaderboard(LocalDate today) throws QException
|
||||
{
|
||||
LocalDate start = today.withMonth(((today.getMonthValue() - 1) / 3) * 3 + 1).withDayOfMonth(1);
|
||||
QQueryFilter filter = period(dealStatus(true), "actualCloseDate", start, today.plusDays(1));
|
||||
GroupBy owner = new GroupBy(QFieldType.STRING, "ownerUserId");
|
||||
List<AggregateResult> results = aggregate(DEAL, filter, List.of(COUNT, AMOUNT), List.of(owner));
|
||||
results.sort(Comparator.comparing((AggregateResult result) -> number(result.getAggregateValue(AMOUNT))).reversed());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(AggregateResult result : results)
|
||||
{
|
||||
Serializable user = result.getGroupByValue(owner);
|
||||
QQueryFilter ownerFilter = filter.clone().withCriteria(new QFilterCriteria("ownerUserId", user == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, user));
|
||||
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, ownerFilter), Objects.toString(user, "Unassigned"),
|
||||
number(result.getAggregateValue(COUNT)), number(result.getAggregateValue(AMOUNT))));
|
||||
}
|
||||
return (table(rows, "Owner", "Won deals", "Won amount (base)"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate currency groups instead of loading the underlying deals.
|
||||
*******************************************************************************/
|
||||
private Map<Serializable, BigDecimal[]> groupedTotals(QQueryFilter filter, GroupBy group) throws QException
|
||||
{
|
||||
List<GroupBy> groups = new ArrayList<>(List.of(CURRENCY));
|
||||
if(group != null)
|
||||
{
|
||||
groups.add(group);
|
||||
}
|
||||
Map<Serializable, BigDecimal[]> totals = new LinkedHashMap<>();
|
||||
for(AggregateResult result : aggregate(DEAL, filter, List.of(COUNT, AMOUNT, WEIGHTED), groups))
|
||||
{
|
||||
Serializable key = group == null ? "total" : result.getGroupByValue(group);
|
||||
BigDecimal[] values = totals.computeIfAbsent(key, ignored -> new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
|
||||
values[0] = values[0].add(number(result.getAggregateValue(COUNT)));
|
||||
values[1] = values[1].add(number(result.getAggregateValue(AMOUNT)));
|
||||
values[2] = values[2].add(CrmCurrencyUtils.convertToBaseCurrency(number(result.getAggregateValue(WEIGHTED)), (String) result.getGroupByValue(CURRENCY)));
|
||||
}
|
||||
return (totals);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Totals across all currency buckets.
|
||||
*******************************************************************************/
|
||||
private BigDecimal[] totals(QQueryFilter filter) throws QException
|
||||
{
|
||||
return (groupedTotals(filter, null).getOrDefault("total", new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A scalar aggregate with an explicit zero for an empty selection.
|
||||
*******************************************************************************/
|
||||
private BigDecimal sum(String table, QQueryFilter filter, Aggregate aggregate) throws QException
|
||||
{
|
||||
List<AggregateResult> results = aggregate(table, filter, List.of(aggregate), List.of());
|
||||
return (results.isEmpty() ? BigDecimal.ZERO : number(results.getFirst().getAggregateValue(aggregate)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delegate all permission and record-lock filtering to the core action.
|
||||
*******************************************************************************/
|
||||
private List<AggregateResult> aggregate(String table, QQueryFilter filter, List<Aggregate> aggregates, List<GroupBy> groups) throws QException
|
||||
{
|
||||
return (new AggregateAction().execute(new AggregateInput(table).withFilter(filter).withAggregates(aggregates).withGroupBys(groups)).getResults());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize aggregate values without lossy integer conversions.
|
||||
*******************************************************************************/
|
||||
private BigDecimal number(Serializable value)
|
||||
{
|
||||
return (value == null ? BigDecimal.ZERO : new BigDecimal(value.toString()));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded query with deterministic ordering for tied timestamps.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> query(String table, QQueryFilter filter, String order, boolean ascending, int limit) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter.withOrderBy(new QFilterOrderBy(order, ascending))
|
||||
.withOrderBy(new QFilterOrderBy("id", ascending)).withLimit(limit))).getRecords());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Equality filter.
|
||||
*******************************************************************************/
|
||||
static QQueryFilter equalsFilter(String field, Serializable value)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Half-open reporting interval, avoiding end-of-month time rounding.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter period(QQueryFilter filter, String field, Serializable start, Serializable end)
|
||||
{
|
||||
return (filter.withCriteria(new QFilterCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, start))
|
||||
.withCriteria(new QFilterCriteria(field, QCriteriaOperator.LESS_THAN, end)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every cell navigates to its record, or to the records in an aggregate.
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> row(String href, Object first, Object second, Object third)
|
||||
{
|
||||
return (Map.of("first", link(href, first), "second", link(href, second), "third", link(href, third)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard table cells may contain links; user text is always escaped.
|
||||
*******************************************************************************/
|
||||
private static String link(String href, Object label)
|
||||
{
|
||||
return ("<a href=\"" + escape(href) + "\">" + escape(Objects.toString(label, "")) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape text and attributes in links.
|
||||
*******************************************************************************/
|
||||
private static String escape(String value)
|
||||
{
|
||||
return (value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Three columns, full-width metadata, and the standard paginated table.
|
||||
*******************************************************************************/
|
||||
static TableData table(List<Map<String, Object>> rows, String first, String second, String third)
|
||||
{
|
||||
return (new TableData(null, List.of(new TableData.Column("html", first, "first", "2fr", "left"),
|
||||
new TableData.Column("html", second, "second", "1fr", "left"), new TableData.Column("html", third, "third", "1fr", "left")), rows)
|
||||
.withRowsPerPage(10).withHidePaginationDropdown(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.security.CrmPermissions;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard widgets for the single CRM home. Today holds five statistics.
|
||||
*******************************************************************************/
|
||||
public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput>
|
||||
{
|
||||
public static final List<String> HOME = List.of("crm_today", "crm_pipeline_summary", "crm_deal_forecast", "crm_rotting_deals",
|
||||
"crm_recent_web_leads", "crm_goals", "crm_sales_leaderboard");
|
||||
public static final List<String> TODAY = List.of("crm_open_pipeline", "crm_won_month", "crm_new_contacts", "crm_new_web_leads", "crm_overdue_activities");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All metrics and tables use the same session-aware data renderer.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance)
|
||||
{
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
List<String> labels = List.of("Today", "Pipeline by stage", "Forecast", "Needs attention", "Recent web leads", "Goals", "Leaderboard");
|
||||
for(int i = 0; i < HOME.size(); i++)
|
||||
{
|
||||
output.add(widget(HOME.get(i), labels.get(i), i == 0 ? WidgetType.PARENT_WIDGET : WidgetType.TABLE));
|
||||
}
|
||||
List<String> statistics = List.of("Open pipeline (base currency)", "Won this month (base currency)", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities");
|
||||
for(int i = 0; i < TODAY.size(); i++)
|
||||
{
|
||||
output.add(widget(TODAY.get(i), statistics.get(i), WidgetType.STATISTICS));
|
||||
}
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A full-width, permission-protected standard widget.
|
||||
*******************************************************************************/
|
||||
private QWidgetMetaData widget(String name, String label, WidgetType type)
|
||||
{
|
||||
QWidgetMetaData widget = new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType()).withGridColumns(12)
|
||||
.withIsCard(type != WidgetType.PARENT_WIDGET).withShowReloadButton(true)
|
||||
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
|
||||
.withCodeReference(new QCodeReference(CrmHomeRenderer.class));
|
||||
if("crm_goals".equals(name))
|
||||
{
|
||||
widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class));
|
||||
}
|
||||
return (widget);
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the Pipeline Conversion widget.
|
||||
**
|
||||
** Queries DealStageHistory, counts transitions into each stage (entered)
|
||||
** versus transitions out (progressed), and computes a conversion rate.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders pipeline conversion data as a table widget.
|
||||
*******************************************************************************/
|
||||
public class CrmPipelineConversionRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// query all deal stage history //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput historyOutput = new QueryAction().execute(
|
||||
new QueryInput(DealStageHistory.TABLE_NAME));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// count entered and progressed for each toStageId //
|
||||
///////////////////////////////////////////////////////////
|
||||
Map<Integer, Integer> enteredByStage = new HashMap<>();
|
||||
Map<Integer, Integer> progressedByStage = new HashMap<>();
|
||||
|
||||
for(QRecord record : historyOutput.getRecords())
|
||||
{
|
||||
Integer toStageId = record.getValueInteger("toStageId");
|
||||
Integer fromStageId = record.getValueInteger("fromStageId");
|
||||
|
||||
if(toStageId != null)
|
||||
{
|
||||
enteredByStage.merge(toStageId, 1, Integer::sum);
|
||||
}
|
||||
if(fromStageId != null)
|
||||
{
|
||||
progressedByStage.merge(fromStageId, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// build stage name lookup //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME));
|
||||
|
||||
Map<Integer, String> stageNames = new HashMap<>();
|
||||
for(QRecord record : stageOutput.getRecords())
|
||||
{
|
||||
stageNames.put(record.getValueInteger("id"), record.getValueString("name"));
|
||||
}
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Stage", "stageName", "2fr", null),
|
||||
new TableData.Column("html", "Entered", "entered", "1fr", "right"),
|
||||
new TableData.Column("html", "Progressed", "progressed", "1fr", "right"),
|
||||
new TableData.Column("html", "Conversion %", "conversionRate", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Map.Entry<Integer, Integer> entry : enteredByStage.entrySet())
|
||||
{
|
||||
Integer stageId = entry.getKey();
|
||||
int entered = entry.getValue();
|
||||
int progressed = progressedByStage.getOrDefault(stageId, 0);
|
||||
|
||||
BigDecimal conversionRate = entered > 0
|
||||
? BigDecimal.valueOf(progressed).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(entered), 1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
String stageName = stageNames.getOrDefault(stageId, "Stage " + stageId);
|
||||
|
||||
rows.add(MapBuilder.of(
|
||||
"stageName", stageName,
|
||||
"entered", entered,
|
||||
"progressed", progressed,
|
||||
"conversionRate", conversionRate + "%"
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(25)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the Pipeline Conversion widget.
|
||||
**
|
||||
** Shows conversion rates between pipeline stages based on DealStageHistory
|
||||
** transition data.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_pipeline_conversion widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmPipelineConversionWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_pipeline_conversion";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Pipeline Conversion")
|
||||
.withTooltip("Stage transition conversion rates")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("funnel_chart"))
|
||||
.withCodeReference(new QCodeReference(CrmPipelineConversionRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Pipeline Summary widget.
|
||||
**
|
||||
** Queries deals grouped by pipelineStageId for a given pipelineId and returns
|
||||
** a table with columns: stageName, dealCount, totalAmount, weightedAmount.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the pipeline summary as a table widget showing deal counts and
|
||||
** amounts grouped by pipeline stage.
|
||||
*******************************************************************************/
|
||||
public class CrmPipelineSummaryRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// get pipelineId from widget input query params //
|
||||
///////////////////////////////////////////////////////////////
|
||||
Integer pipelineId = null;
|
||||
if(input.getQueryParams() != null && input.getQueryParams().containsKey("pipelineId"))
|
||||
{
|
||||
pipelineId = Integer.valueOf(input.getQueryParams().get("pipelineId"));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// query all stages for the pipeline, ordered by sortOrder //
|
||||
///////////////////////////////////////////////////////////////
|
||||
QQueryFilter stageFilter = new QQueryFilter()
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder", true));
|
||||
if(pipelineId != null)
|
||||
{
|
||||
stageFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, pipelineId));
|
||||
}
|
||||
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME).withFilter(stageFilter));
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// build a map of stageId -> stageName for display //
|
||||
//////////////////////////////////////////////////////////
|
||||
Map<Integer, String> stageNames = new LinkedHashMap<>();
|
||||
for(QRecord stageRecord : stageOutput.getRecords())
|
||||
{
|
||||
stageNames.put(stageRecord.getValueInteger("id"), stageRecord.getValueString("name"));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// query deals for the pipeline //
|
||||
///////////////////////////////////////////////////////////////
|
||||
QQueryFilter dealFilter = new QQueryFilter();
|
||||
if(pipelineId != null)
|
||||
{
|
||||
dealFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, pipelineId));
|
||||
}
|
||||
|
||||
QueryOutput dealOutput = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME).withFilter(dealFilter));
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// aggregate deals by stage //
|
||||
//////////////////////////////////////////////////////////
|
||||
Map<Integer, Integer> countByStage = new LinkedHashMap<>();
|
||||
Map<Integer, BigDecimal> totalAmountByStage = new LinkedHashMap<>();
|
||||
Map<Integer, BigDecimal> weightedByStage = new LinkedHashMap<>();
|
||||
|
||||
for(Integer stageId : stageNames.keySet())
|
||||
{
|
||||
countByStage.put(stageId, 0);
|
||||
totalAmountByStage.put(stageId, BigDecimal.ZERO);
|
||||
weightedByStage.put(stageId, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
for(QRecord dealRecord : dealOutput.getRecords())
|
||||
{
|
||||
Integer stageId = dealRecord.getValueInteger("pipelineStageId");
|
||||
if(stageId == null || !stageNames.containsKey(stageId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
countByStage.merge(stageId, 1, Integer::sum);
|
||||
|
||||
BigDecimal amount = dealRecord.getValueBigDecimal("amount");
|
||||
if(amount != null)
|
||||
{
|
||||
totalAmountByStage.merge(stageId, amount, BigDecimal::add);
|
||||
}
|
||||
|
||||
BigDecimal weighted = dealRecord.getValueBigDecimal("weightedAmount");
|
||||
if(weighted != null)
|
||||
{
|
||||
weightedByStage.merge(stageId, weighted, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Stage", "stageName", "2fr", null),
|
||||
new TableData.Column("html", "Deal Count", "dealCount", "1fr", "right"),
|
||||
new TableData.Column("html", "Total Amount", "totalAmount", "1fr", "right"),
|
||||
new TableData.Column("html", "Weighted Amount", "weightedAmount", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Integer stageId : stageNames.keySet())
|
||||
{
|
||||
rows.add(MapBuilder.of(
|
||||
"stageName", stageNames.get(stageId),
|
||||
"dealCount", countByStage.getOrDefault(stageId, 0),
|
||||
"totalAmount", totalAmountByStage.getOrDefault(stageId, BigDecimal.ZERO).toPlainString(),
|
||||
"weightedAmount", weightedByStage.getOrDefault(stageId, BigDecimal.ZERO).toPlainString()
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(50)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Pipeline Summary widget.
|
||||
**
|
||||
** Provides a TABLE view showing deal counts and amounts grouped by pipeline
|
||||
** stage for a given pipeline.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_pipeline_summary widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmPipelineSummaryWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_pipeline_summary";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Pipeline Summary")
|
||||
.withTooltip("Deal counts and amounts by pipeline stage")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("linear_scale"))
|
||||
.withCodeReference(new QCodeReference(CrmPipelineSummaryRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Quota Attainment widget.
|
||||
**
|
||||
** Queries the SalesGoal for the current user and current period, then queries
|
||||
** won deals in that period to compute attainment percentage.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qbits.crm.scoring.model.SalesGoal;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders quota attainment as multi-statistics showing target, actual, and
|
||||
** attainment percentage.
|
||||
*******************************************************************************/
|
||||
public class CrmQuotaAttainmentRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String currentUserId = QContext.getQSession() != null
|
||||
? QContext.getQSession().getIdReference() : null;
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query SalesGoal for current user/period //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput goalQuery = new QueryAction().execute(
|
||||
new QueryInput(SalesGoal.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, currentUserId))
|
||||
.withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today))
|
||||
.withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today))));
|
||||
|
||||
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
|
||||
|
||||
if(goalQuery.getRecords().isEmpty())
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// no goal found -- show empty state //
|
||||
///////////////////////////////////////////////
|
||||
groups.add(new MultiStatisticsData.StatisticsGroupData()
|
||||
.withHeader("No Goal Set")
|
||||
.withSubheader("current period")
|
||||
.withStatisticList(List.of(
|
||||
new MultiStatisticsData.StatisticsGroupData.Statistic("Attainment", 0, "%")
|
||||
)));
|
||||
}
|
||||
else
|
||||
{
|
||||
SalesGoal goal = new SalesGoal(goalQuery.getRecords().get(0));
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// find closed-won stage ids //
|
||||
///////////////////////////////////////////////
|
||||
QueryOutput stageQuery = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
|
||||
|
||||
List<Integer> closedWonStageIds = stageQuery.getRecords().stream()
|
||||
.map(r -> r.getValueInteger("id"))
|
||||
.toList();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// query won deals in the period //
|
||||
///////////////////////////////////////////////
|
||||
BigDecimal totalWon = BigDecimal.ZERO;
|
||||
|
||||
if(!closedWonStageIds.isEmpty())
|
||||
{
|
||||
QQueryFilter dealFilter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, currentUserId))
|
||||
.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, closedWonStageIds))
|
||||
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, goal.getPeriodStart()))
|
||||
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, goal.getPeriodEnd()));
|
||||
|
||||
if(goal.getPipelineId() != null)
|
||||
{
|
||||
dealFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, goal.getPipelineId()));
|
||||
}
|
||||
|
||||
QueryOutput dealQuery = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME).withFilter(dealFilter));
|
||||
|
||||
for(QRecord dealRecord : dealQuery.getRecords())
|
||||
{
|
||||
BigDecimal amount = dealRecord.getValueBigDecimal("amountInBaseCurrency");
|
||||
if(amount != null)
|
||||
{
|
||||
totalWon = totalWon.add(amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// compute attainment percentage //
|
||||
///////////////////////////////////////////////
|
||||
int attainmentPct = 0;
|
||||
if(goal.getTargetAmount() != null && goal.getTargetAmount().compareTo(BigDecimal.ZERO) > 0)
|
||||
{
|
||||
attainmentPct = totalWon
|
||||
.multiply(new BigDecimal(100))
|
||||
.divide(goal.getTargetAmount(), 0, RoundingMode.HALF_UP)
|
||||
.intValue();
|
||||
}
|
||||
|
||||
groups.add(new MultiStatisticsData.StatisticsGroupData()
|
||||
.withHeader("Quota Attainment")
|
||||
.withSubheader(goal.getPeriodStart() + " to " + goal.getPeriodEnd())
|
||||
.withStatisticList(List.of(
|
||||
new MultiStatisticsData.StatisticsGroupData.Statistic("Target", goal.getTargetAmount().intValue(), null),
|
||||
new MultiStatisticsData.StatisticsGroupData.Statistic("Actual", totalWon.intValue(), null),
|
||||
new MultiStatisticsData.StatisticsGroupData.Statistic("Attainment", attainmentPct, "%")
|
||||
)));
|
||||
}
|
||||
|
||||
MultiStatisticsData data = new MultiStatisticsData("Quota Attainment", groups);
|
||||
|
||||
return (new RenderWidgetOutput(data));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Quota Attainment widget.
|
||||
**
|
||||
** Provides a MULTI_STATISTICS view showing quota attainment percentage for
|
||||
** the current user and current period.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_quota_attainment widget (MULTI_STATISTICS type).
|
||||
*******************************************************************************/
|
||||
public class CrmQuotaAttainmentWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_quota_attainment";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.MULTI_STATISTICS.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Quota Attainment")
|
||||
.withTooltip("Your quota attainment for the current period")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new QIcon("flag"))
|
||||
.withCodeReference(new QCodeReference(CrmQuotaAttainmentRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the Recent Deals widget.
|
||||
**
|
||||
** Queries deals with actualCloseDate set, ordered by actualCloseDate DESC,
|
||||
** limited to 20 rows. Returns table with name, amount, owner, closedDate,
|
||||
** winLossReason.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders recent deals as a table widget.
|
||||
*******************************************************************************/
|
||||
public class CrmRecentDealsRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// query recent deals with actualCloseDate set //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput queryOutput = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK))
|
||||
.withOrderBy(new QFilterOrderBy("actualCloseDate", false))
|
||||
.withLimit(20)));
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Name", "name", "2fr", null),
|
||||
new TableData.Column("html", "Amount", "amount", "1fr", "right"),
|
||||
new TableData.Column("html", "Owner", "ownerUserId", "1fr", null),
|
||||
new TableData.Column("html", "Closed Date", "closedDate", "1fr", "right"),
|
||||
new TableData.Column("html", "Win/Loss Reason", "winLossReason", "1fr", null)
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
String name = record.getValueString("name");
|
||||
if(name == null)
|
||||
{
|
||||
name = "";
|
||||
}
|
||||
|
||||
BigDecimal amount = record.getValueBigDecimal("amount");
|
||||
String amountDisplay = amount != null ? "$" + amount.toPlainString() : "-";
|
||||
|
||||
String ownerUserId = record.getValueString("ownerUserId");
|
||||
if(ownerUserId == null)
|
||||
{
|
||||
ownerUserId = "-";
|
||||
}
|
||||
|
||||
LocalDate closedDate = record.getValueLocalDate("actualCloseDate");
|
||||
String closedDateDisplay = closedDate != null ? closedDate.toString() : "-";
|
||||
|
||||
Integer winLossReasonId = record.getValueInteger("winLossReasonId");
|
||||
String winLossDisplay = winLossReasonId != null ? String.valueOf(winLossReasonId) : "-";
|
||||
|
||||
rows.add(MapBuilder.of(
|
||||
"name", name,
|
||||
"amount", amountDisplay,
|
||||
"ownerUserId", ownerUserId,
|
||||
"closedDate", closedDateDisplay,
|
||||
"winLossReason", winLossDisplay
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(20)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the Recent Deals widget.
|
||||
**
|
||||
** Shows the most recently closed deals with key details.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_recent_deals widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmRecentDealsWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_recent_deals";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Recent Deals")
|
||||
.withTooltip("Most recently closed deals")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("receipt_long"))
|
||||
.withCodeReference(new QCodeReference(CrmRecentDealsRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Rotting Deals widget.
|
||||
**
|
||||
** Queries deals and their pipeline stages, finds deals where the number of
|
||||
** days since stageEnteredDate exceeds the stage's rotDays threshold.
|
||||
** Returns a table with columns: dealName, stageName, daysInStage, rotDays,
|
||||
** ownerUserId, amount.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the rotting deals table showing deals past their stage rot threshold.
|
||||
*******************************************************************************/
|
||||
public class CrmRottingDealsRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// query all stages that have a rotDays value set //
|
||||
///////////////////////////////////////////////////////////////
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("rotDays", QCriteriaOperator.IS_NOT_BLANK))));
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// build a map of stageId -> PipelineStage for lookup //
|
||||
///////////////////////////////////////////////////////////////
|
||||
Map<Integer, PipelineStage> stageMap = new HashMap<>();
|
||||
for(QRecord stageRecord : stageOutput.getRecords())
|
||||
{
|
||||
PipelineStage stage = new PipelineStage(stageRecord);
|
||||
stageMap.put(stage.getId(), stage);
|
||||
}
|
||||
|
||||
if(stageMap.isEmpty())
|
||||
{
|
||||
//////////////////////////////////////////////////////////
|
||||
// no stages with rot days -- return empty table //
|
||||
//////////////////////////////////////////////////////////
|
||||
return (buildEmptyOutput());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// query deals in those stages //
|
||||
///////////////////////////////////////////////////////////////
|
||||
QueryOutput dealOutput = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, new ArrayList<>(stageMap.keySet())))));
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Deal", "dealName", "2fr", null),
|
||||
new TableData.Column("html", "Stage", "stageName", "1fr", null),
|
||||
new TableData.Column("html", "Days in Stage", "daysInStage", "1fr", "right"),
|
||||
new TableData.Column("html", "Rot Days", "rotDays", "1fr", "right"),
|
||||
new TableData.Column("html", "Owner", "ownerUserId", "1fr", null),
|
||||
new TableData.Column("html", "Amount", "amount", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// filter to only deals past the rot threshold //
|
||||
//////////////////////////////////////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord dealRecord : dealOutput.getRecords())
|
||||
{
|
||||
Integer stageId = dealRecord.getValueInteger("pipelineStageId");
|
||||
PipelineStage stage = stageMap.get(stageId);
|
||||
|
||||
if(stage == null || stage.getRotDays() == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Instant stageEnteredDate = dealRecord.getValueInstant("stageEnteredDate");
|
||||
if(stageEnteredDate == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long daysInStage = ChronoUnit.DAYS.between(stageEnteredDate, now);
|
||||
if(daysInStage > stage.getRotDays())
|
||||
{
|
||||
String dealName = dealRecord.getValueString("name");
|
||||
String ownerUserId = dealRecord.getValueString("ownerUserId");
|
||||
BigDecimal amount = dealRecord.getValueBigDecimal("amount");
|
||||
|
||||
rows.add(MapBuilder.of(
|
||||
"dealName", dealName != null ? dealName : "",
|
||||
"stageName", stage.getName(),
|
||||
"daysInStage", (int) daysInStage,
|
||||
"rotDays", stage.getRotDays(),
|
||||
"ownerUserId", ownerUserId != null ? ownerUserId : "",
|
||||
"amount", amount != null ? amount.toPlainString() : "0"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(50)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build an empty table output when there are no rotting deals.
|
||||
***************************************************************************/
|
||||
private RenderWidgetOutput buildEmptyOutput()
|
||||
{
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Deal", "dealName", "2fr", null),
|
||||
new TableData.Column("html", "Stage", "stageName", "1fr", null),
|
||||
new TableData.Column("html", "Days in Stage", "daysInStage", "1fr", "right"),
|
||||
new TableData.Column("html", "Rot Days", "rotDays", "1fr", "right"),
|
||||
new TableData.Column("html", "Owner", "ownerUserId", "1fr", null),
|
||||
new TableData.Column("html", "Amount", "amount", "1fr", "right")
|
||||
);
|
||||
|
||||
TableData tableData = new TableData(null, columns, new ArrayList<>())
|
||||
.withRowsPerPage(50)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Rotting Deals widget.
|
||||
**
|
||||
** Provides a TABLE view showing deals that have exceeded their current
|
||||
** stage's rotDays threshold, indicating they need attention.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_rotting_deals widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmRottingDealsWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_rotting_deals";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Rotting Deals")
|
||||
.withTooltip("Deals that have exceeded their stage's rot days threshold")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("warning"))
|
||||
.withCodeReference(new QCodeReference(CrmRottingDealsRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the Sales Cycle Length widget.
|
||||
**
|
||||
** Queries closedWon deals and computes the average number of days from
|
||||
** createDate to actualCloseDate.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders sales cycle length as a single-stat table widget.
|
||||
*******************************************************************************/
|
||||
public class CrmSalesCycleLengthRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// find closedWon stage IDs //
|
||||
///////////////////////////////////////////////////////////
|
||||
Set<Integer> closedWonStageIds = new HashSet<>();
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
|
||||
|
||||
for(QRecord record : stageOutput.getRecords())
|
||||
{
|
||||
closedWonStageIds.add(record.getValueInteger("id"));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// query deals with actualCloseDate set //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput dealOutput = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK))));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// compute average days for closedWon deals //
|
||||
///////////////////////////////////////////////////////////
|
||||
long totalDays = 0;
|
||||
int count = 0;
|
||||
|
||||
for(QRecord record : dealOutput.getRecords())
|
||||
{
|
||||
Integer stageId = record.getValueInteger("pipelineStageId");
|
||||
if(closedWonStageIds.contains(stageId))
|
||||
{
|
||||
Instant createDate = record.getValueInstant("createDate");
|
||||
LocalDate actualCloseDate = record.getValueLocalDate("actualCloseDate");
|
||||
|
||||
if(createDate != null && actualCloseDate != null)
|
||||
{
|
||||
LocalDate createLocalDate = createDate.atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
long days = ChronoUnit.DAYS.between(createLocalDate, actualCloseDate);
|
||||
totalDays += days;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BigDecimal avgCycleLength = count > 0
|
||||
? BigDecimal.valueOf(totalDays).divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
///////////////////////////
|
||||
// build table output //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Metric", "metric", "1fr", null),
|
||||
new TableData.Column("html", "Value", "value", "1fr", "right")
|
||||
);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(MapBuilder.of(
|
||||
"metric", "Avg Sales Cycle (days)",
|
||||
"value", avgCycleLength.toPlainString()
|
||||
));
|
||||
rows.add(MapBuilder.of(
|
||||
"metric", "Closed Won Deals",
|
||||
"value", String.valueOf(count)
|
||||
));
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(10)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the Sales Cycle Length widget.
|
||||
**
|
||||
** Shows the average number of days from deal creation to close for won deals.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_sales_cycle_length widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmSalesCycleLengthWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_sales_cycle_length";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(3)
|
||||
.withIsCard(true)
|
||||
.withLabel("Sales Cycle Length")
|
||||
.withTooltip("Average days from creation to close for won deals")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("schedule"))
|
||||
.withCodeReference(new QCodeReference(CrmSalesCycleLengthRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the CRM Sales Leaderboard widget.
|
||||
**
|
||||
** Queries deals in closed-won stages, groups by ownerUserId, sums
|
||||
** amountInBaseCurrency, and returns a ranked table.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the sales leaderboard as a table widget, ranking reps by closed-won
|
||||
** deal revenue.
|
||||
*******************************************************************************/
|
||||
public class CrmSalesLeaderboardRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// find closed-won stage ids //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput stageQuery = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true))));
|
||||
|
||||
List<Integer> closedWonStageIds = stageQuery.getRecords().stream()
|
||||
.map(r -> r.getValueInteger("id"))
|
||||
.toList();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// query deals in closed-won stages //
|
||||
///////////////////////////////////////////////////////////
|
||||
Map<String, BigDecimal> revenueByOwner = new LinkedHashMap<>();
|
||||
|
||||
if(!closedWonStageIds.isEmpty())
|
||||
{
|
||||
QueryOutput dealQuery = new QueryAction().execute(
|
||||
new QueryInput(Deal.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, closedWonStageIds))));
|
||||
|
||||
for(QRecord dealRecord : dealQuery.getRecords())
|
||||
{
|
||||
String ownerUserId = dealRecord.getValueString("ownerUserId");
|
||||
BigDecimal amount = dealRecord.getValueBigDecimal("amountInBaseCurrency");
|
||||
if(ownerUserId != null && amount != null)
|
||||
{
|
||||
revenueByOwner.merge(ownerUserId, amount, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Rank", "rank", "0.5fr", null),
|
||||
new TableData.Column("html", "Owner", "owner", "2fr", null),
|
||||
new TableData.Column("html", "Revenue", "revenue", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build ranked rows //
|
||||
//////////////////////////
|
||||
List<Map.Entry<String, BigDecimal>> sorted = new ArrayList<>(revenueByOwner.entrySet());
|
||||
sorted.sort(Comparator.comparing(Map.Entry<String, BigDecimal>::getValue).reversed());
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
int rank = 1;
|
||||
for(Map.Entry<String, BigDecimal> entry : sorted)
|
||||
{
|
||||
rows.add(MapBuilder.of(
|
||||
"rank", String.valueOf(rank),
|
||||
"owner", entry.getKey(),
|
||||
"revenue", entry.getValue().toPlainString()
|
||||
));
|
||||
rank++;
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(25)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the CRM Sales Leaderboard widget.
|
||||
**
|
||||
** Provides a TABLE view showing deals grouped by owner with total revenue
|
||||
** from closed-won deals, ranked descending.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_sales_leaderboard widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmSalesLeaderboardWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_sales_leaderboard";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Sales Leaderboard")
|
||||
.withTooltip("Top performers by closed-won deal revenue")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new QIcon("leaderboard"))
|
||||
.withCodeReference(new QCodeReference(CrmSalesLeaderboardRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the Stage Duration widget.
|
||||
**
|
||||
** Queries DealStageHistory where durationInFromStageDays is not null,
|
||||
** computes average and median per fromStageId.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders stage duration analytics as a table widget.
|
||||
*******************************************************************************/
|
||||
public class CrmStageDurationRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// query history with durationInFromStageDays populated //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput historyOutput = new QueryAction().execute(
|
||||
new QueryInput(DealStageHistory.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("durationInFromStageDays", QCriteriaOperator.IS_NOT_BLANK))));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// group durations by fromStageId //
|
||||
///////////////////////////////////////////////////////////
|
||||
Map<Integer, List<Integer>> durationsByStage = new HashMap<>();
|
||||
for(QRecord record : historyOutput.getRecords())
|
||||
{
|
||||
Integer fromStageId = record.getValueInteger("fromStageId");
|
||||
Integer duration = record.getValueInteger("durationInFromStageDays");
|
||||
if(fromStageId != null && duration != null)
|
||||
{
|
||||
durationsByStage.computeIfAbsent(fromStageId, k -> new ArrayList<>()).add(duration);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// build stage name lookup //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME));
|
||||
|
||||
Map<Integer, String> stageNames = new HashMap<>();
|
||||
for(QRecord record : stageOutput.getRecords())
|
||||
{
|
||||
stageNames.put(record.getValueInteger("id"), record.getValueString("name"));
|
||||
}
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Stage", "stageName", "2fr", null),
|
||||
new TableData.Column("html", "Avg Days", "avgDays", "1fr", "right"),
|
||||
new TableData.Column("html", "Median Days", "medianDays", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Map.Entry<Integer, List<Integer>> entry : durationsByStage.entrySet())
|
||||
{
|
||||
Integer stageId = entry.getKey();
|
||||
List<Integer> durations = entry.getValue();
|
||||
|
||||
BigDecimal avg = BigDecimal.valueOf(durations.stream().mapToInt(Integer::intValue).average().orElse(0))
|
||||
.setScale(1, RoundingMode.HALF_UP);
|
||||
|
||||
Collections.sort(durations);
|
||||
int medianIndex = durations.size() / 2;
|
||||
int median = durations.size() % 2 == 0
|
||||
? (durations.get(medianIndex - 1) + durations.get(medianIndex)) / 2
|
||||
: durations.get(medianIndex);
|
||||
|
||||
String stageName = stageNames.getOrDefault(stageId, "Stage " + stageId);
|
||||
|
||||
rows.add(MapBuilder.of(
|
||||
"stageName", stageName,
|
||||
"avgDays", avg,
|
||||
"medianDays", median
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(25)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the Stage Duration widget.
|
||||
**
|
||||
** Shows average and median days spent in each pipeline stage.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_stage_duration widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmStageDurationWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_stage_duration";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Stage Duration")
|
||||
.withTooltip("Average and median days per pipeline stage")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("timer"))
|
||||
.withCodeReference(new QCodeReference(CrmStageDurationRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the Win Rate widget.
|
||||
**
|
||||
** Queries DealStageHistory for transitions to closedWon and closedLost stages,
|
||||
** groups by month, and computes win rate percentage.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
|
||||
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders win rate analytics as a table widget.
|
||||
*******************************************************************************/
|
||||
public class CrmWinRateRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// find closedWon and closedLost stage IDs //
|
||||
///////////////////////////////////////////////////////////
|
||||
Set<Integer> closedWonStageIds = new HashSet<>();
|
||||
Set<Integer> closedLostStageIds = new HashSet<>();
|
||||
|
||||
QueryOutput stageOutput = new QueryAction().execute(
|
||||
new QueryInput(PipelineStage.TABLE_NAME));
|
||||
|
||||
for(QRecord record : stageOutput.getRecords())
|
||||
{
|
||||
if(Boolean.TRUE.equals(record.getValueBoolean("isClosedWon")))
|
||||
{
|
||||
closedWonStageIds.add(record.getValueInteger("id"));
|
||||
}
|
||||
if(Boolean.TRUE.equals(record.getValueBoolean("isClosedLost")))
|
||||
{
|
||||
closedLostStageIds.add(record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// query all deal stage history //
|
||||
///////////////////////////////////////////////////////////
|
||||
QueryOutput historyOutput = new QueryAction().execute(
|
||||
new QueryInput(DealStageHistory.TABLE_NAME));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// group by month //
|
||||
///////////////////////////////////////////////////////////
|
||||
Map<YearMonth, int[]> monthData = new TreeMap<>(); // [won, lost]
|
||||
|
||||
for(QRecord record : historyOutput.getRecords())
|
||||
{
|
||||
Integer toStageId = record.getValueInteger("toStageId");
|
||||
Instant transitionDate = record.getValueInstant("transitionDate");
|
||||
|
||||
if(toStageId == null || transitionDate == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean isWon = closedWonStageIds.contains(toStageId);
|
||||
boolean isLost = closedLostStageIds.contains(toStageId);
|
||||
|
||||
if(!isWon && !isLost)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
YearMonth month = YearMonth.from(transitionDate.atZone(ZoneId.systemDefault()));
|
||||
int[] counts = monthData.computeIfAbsent(month, k -> new int[2]);
|
||||
if(isWon)
|
||||
{
|
||||
counts[0]++;
|
||||
}
|
||||
if(isLost)
|
||||
{
|
||||
counts[1]++;
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////
|
||||
// build table columns //
|
||||
///////////////////////////
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("html", "Month", "month", "1fr", null),
|
||||
new TableData.Column("html", "Won", "won", "1fr", "right"),
|
||||
new TableData.Column("html", "Lost", "lost", "1fr", "right"),
|
||||
new TableData.Column("html", "Win Rate %", "winRate", "1fr", "right")
|
||||
);
|
||||
|
||||
//////////////////////////
|
||||
// build table rows //
|
||||
//////////////////////////
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Map.Entry<YearMonth, int[]> entry : monthData.entrySet())
|
||||
{
|
||||
int won = entry.getValue()[0];
|
||||
int lost = entry.getValue()[1];
|
||||
int total = won + lost;
|
||||
|
||||
BigDecimal winRate = total > 0
|
||||
? BigDecimal.valueOf(won).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
rows.add(MapBuilder.of(
|
||||
"month", entry.getKey().toString(),
|
||||
"won", won,
|
||||
"lost", lost,
|
||||
"winRate", winRate + "%"
|
||||
));
|
||||
}
|
||||
|
||||
TableData tableData = new TableData(null, columns, rows)
|
||||
.withRowsPerPage(25)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the Win Rate widget.
|
||||
**
|
||||
** Shows won/lost counts and win rate percentage grouped by month.
|
||||
*******************************************************************************/
|
||||
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces metadata for the crm_win_rate widget (TABLE type).
|
||||
*******************************************************************************/
|
||||
public class CrmWinRateWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "crm_win_rate";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(6)
|
||||
.withIsCard(true)
|
||||
.withLabel("Win Rate")
|
||||
.withTooltip("Monthly win/loss counts and win rate")
|
||||
.withShowReloadButton(true)
|
||||
.withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("analytics"))
|
||||
.withCodeReference(new QCodeReference(CrmWinRateRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -76,6 +76,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_activity_participant_user_id ON crm_activity_p
|
||||
|
||||
--changeset qbit-crm:020-crm_activity_type
|
||||
CREATE TABLE IF NOT EXISTS crm_activity_type (
|
||||
code VARCHAR(50) UNIQUE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
icon_name VARCHAR(50),
|
||||
@@ -153,6 +154,8 @@ CREATE INDEX IF NOT EXISTS ix_crm_company_parent_company_id ON crm_company (pare
|
||||
|
||||
--changeset qbit-crm:020-crm_consent_record
|
||||
CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
consent_text TEXT,
|
||||
evidence_form_submission_id INTEGER,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
contact_id INTEGER NOT NULL,
|
||||
consent_type INTEGER NOT NULL,
|
||||
@@ -161,7 +164,6 @@ CREATE TABLE IF NOT EXISTS crm_consent_record (
|
||||
source VARCHAR(250),
|
||||
consent_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
expiry_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
@@ -169,6 +171,22 @@ CREATE INDEX IF NOT EXISTS ix_crm_consent_record_contact_id ON crm_consent_recor
|
||||
|
||||
--changeset qbit-crm:020-crm_contact
|
||||
CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
first_touch_utm_source VARCHAR(250),
|
||||
first_touch_utm_medium VARCHAR(250),
|
||||
first_touch_utm_campaign VARCHAR(250),
|
||||
first_touch_utm_content VARCHAR(250),
|
||||
first_touch_utm_term VARCHAR(250),
|
||||
first_touch_landing_page VARCHAR(1000),
|
||||
first_touch_referrer VARCHAR(1000),
|
||||
first_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_touch_utm_source VARCHAR(250),
|
||||
last_touch_utm_medium VARCHAR(250),
|
||||
last_touch_utm_campaign VARCHAR(250),
|
||||
last_touch_utm_content VARCHAR(250),
|
||||
last_touch_utm_term VARCHAR(250),
|
||||
last_touch_landing_page VARCHAR(1000),
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
@@ -246,6 +264,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
|
||||
probability_override_pct INTEGER,
|
||||
weighted_amount DECIMAL(20,6),
|
||||
company_id INTEGER,
|
||||
primary_contact_id INTEGER,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
lead_source_id INTEGER,
|
||||
expected_close_date DATE,
|
||||
@@ -261,6 +280,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_id ON crm_deal (pipeline_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_stage_id ON crm_deal (pipeline_stage_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_primary_contact_id ON crm_deal (primary_contact_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_company_id ON crm_deal (company_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_owner_user_id ON crm_deal (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_deal_lead_source_id ON crm_deal (lead_source_id);
|
||||
@@ -317,6 +337,9 @@ CREATE INDEX IF NOT EXISTS ix_crm_deal_stage_history_user_id ON crm_deal_stage_h
|
||||
|
||||
--changeset qbit-crm:020-crm_email_list
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
list_key VARCHAR(250) NOT NULL UNIQUE,
|
||||
require_double_opt_in BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(250) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
@@ -333,13 +356,18 @@ CREATE TABLE IF NOT EXISTS crm_email_list (
|
||||
|
||||
--changeset qbit-crm:020-crm_email_list_member
|
||||
CREATE TABLE IF NOT EXISTS crm_email_list_member (
|
||||
confirmation_token_hash VARCHAR(64),
|
||||
confirmation_sent_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
source VARCHAR(250),
|
||||
consent_text TEXT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email_list_id INTEGER NOT NULL,
|
||||
contact_id INTEGER,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
status INTEGER DEFAULT 1 NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
subscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
unsubscribed_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
subscription_source VARCHAR(250),
|
||||
form_submission_id INTEGER,
|
||||
@@ -356,6 +384,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
|
||||
CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
contact_id INTEGER,
|
||||
from_address VARCHAR(250) NOT NULL,
|
||||
from_name VARCHAR(100),
|
||||
to_addresses TEXT NOT NULL,
|
||||
@@ -382,6 +411,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_template_id ON crm_email_message (email_template_id);
|
||||
@@ -440,6 +470,12 @@ CREATE INDEX IF NOT EXISTS ix_crm_external_mapping_external_id ON crm_external_m
|
||||
|
||||
--changeset qbit-crm:020-crm_form_submission
|
||||
CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
form_key VARCHAR(250) NOT NULL,
|
||||
marketing_consent BOOLEAN DEFAULT FALSE,
|
||||
consent_text TEXT,
|
||||
landing_page VARCHAR(1000),
|
||||
visitor_key VARCHAR(64),
|
||||
client_hash VARCHAR(64),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
form_type INTEGER NOT NULL,
|
||||
email VARCHAR(250) NOT NULL,
|
||||
@@ -459,8 +495,6 @@ CREATE TABLE IF NOT EXISTS crm_form_submission (
|
||||
conversion_status INTEGER DEFAULT 1 NOT NULL,
|
||||
converted_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
converted_by_user_id TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
metadata TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE
|
||||
@@ -638,3 +672,5 @@ CREATE TABLE IF NOT EXISTS crm_win_loss_reason (
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT uq_crm_win_loss_reason_1 UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crm_consent_record_evidence_form_submission_id ON crm_consent_record (evidence_form_submission_id);
|
||||
@@ -124,4 +124,37 @@ public class BaseTest
|
||||
CrmQBitProducer.GROUP_ID + ":" + CrmQBitProducer.ARTIFACT_ID);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing operation regressions run helpers directly. CrmActionsTest covers ETL.
|
||||
*******************************************************************************/
|
||||
protected com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput runBusinessStep(
|
||||
com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput input) throws QException
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.actions.processes.BackendStep step;
|
||||
step = switch(input.getProcessName())
|
||||
{
|
||||
case "crm_auto_assign" -> new com.kingsrook.qbits.crm.scoring.processes.AutoAssignProcess();
|
||||
case "crm_enroll_in_sequence" -> new com.kingsrook.qbits.crm.email.processes.EnrollInSequenceProcess();
|
||||
case "crm_unenroll_from_sequence" -> new com.kingsrook.qbits.crm.email.processes.UnenrollFromSequenceProcess();
|
||||
case "crm_clone_deal" -> new com.kingsrook.qbits.crm.deals.processes.CloneDealProcess();
|
||||
case "crm_convert_lead" -> new com.kingsrook.qbits.crm.deals.processes.ConvertLeadProcess();
|
||||
case "crm_manage_contact_on_deal" -> new com.kingsrook.qbits.crm.deals.processes.ManageContactOnDealProcess();
|
||||
case "crm_move_deal_stage" -> new com.kingsrook.qbits.crm.deals.processes.MoveDealStageProcess();
|
||||
case "crm_reopen_deal" -> new com.kingsrook.qbits.crm.deals.processes.ReopenDealProcess();
|
||||
case "crm_transfer_ownership" -> new com.kingsrook.qbits.crm.deals.processes.TransferOwnershipProcess();
|
||||
case "crm_convert_form_to_contact" -> new com.kingsrook.qbits.crm.core.processes.ConvertFormToContactProcess();
|
||||
case "crm_detect_duplicates" -> new com.kingsrook.qbits.crm.core.processes.DetectDuplicatesProcess();
|
||||
case "crm_gdpr_anonymize_contact" -> new com.kingsrook.qbits.crm.core.processes.GdprAnonymizeContactProcess();
|
||||
case "crm_merge_companies" -> new com.kingsrook.qbits.crm.core.processes.MergeCompaniesProcess();
|
||||
case "crm_merge_contacts" -> new com.kingsrook.qbits.crm.core.processes.MergeContactsProcess();
|
||||
case "crm_subscribe_to_list" -> new com.kingsrook.qbits.crm.core.processes.SubscribeToListProcess();
|
||||
case "crm_unsubscribe_from_list" -> new com.kingsrook.qbits.crm.core.processes.UnsubscribeFromListProcess();
|
||||
case "crm_log_activity" -> new com.kingsrook.qbits.crm.activities.processes.LogActivityProcess();
|
||||
default -> throw (new IllegalArgumentException(input.getProcessName()));
|
||||
};
|
||||
com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
|
||||
step.run(input, output);
|
||||
return (output);
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user